From c8035b243d8d389bcc85616e58ae6284cd455c95 Mon Sep 17 00:00:00 2001 From: Paperfoot Date: Thu, 10 Sep 2026 23:00:18 +0100 Subject: [PATCH 1/3] Modernize contract drafting, legal profiles and PDF rendering --- .github/workflows/ci.yml | 32 +- .gitignore | 5 +- CHANGELOG.md | 16 + Cargo.lock | 1984 ++++++++++++++++++ Cargo.toml | 9 +- README.md | 265 +-- clauses/archive/consulting/standard-1.1.toml | 107 + clauses/archive/loan/standard-1.0.toml | 118 ++ clauses/archive/msa/standard-1.0.toml | 79 + clauses/archive/ncnda/standard-1.0.toml | 162 ++ clauses/archive/nda/standard-1.1.toml | 166 ++ clauses/archive/service/standard-1.0.toml | 81 + clauses/archive/sow/standard-1.0.toml | 67 + clauses/consulting/design.toml | 128 ++ clauses/consulting/standard.toml | 57 +- clauses/consulting/technology.toml | 122 ++ clauses/loan/standard.toml | 34 +- clauses/msa/standard.toml | 45 +- clauses/msa/startup.toml | 92 + clauses/ncnda/standard.toml | 63 +- clauses/nda/standard.toml | 58 +- clauses/service/standard.toml | 55 +- clauses/sow/standard.toml | 21 +- docs/LEGAL.md | 47 + scripts/smoke-pdfs.py | 316 +++ src/clauses.rs | 26 +- src/cli.rs | 26 +- src/commands/agent_info.rs | 156 +- src/commands/clauses.rs | 25 +- src/commands/clients.rs | 46 +- src/commands/config.rs | 21 +- src/commands/contracts.rs | 271 ++- src/commands/doctor.rs | 2 +- src/commands/issuers.rs | 51 +- src/commands/kinds.rs | 2 +- src/commands/pack.rs | 4 +- src/commands/skill.rs | 2 +- src/commands/template.rs | 67 +- src/commands/update.rs | 52 +- src/db.rs | 139 +- src/kinds.rs | 93 +- src/legal.rs | 233 ++ src/lib.rs | 3 +- src/render.rs | 293 ++- src/typst_assets.rs | 35 +- tests/contracts.rs | 52 +- tests/draft_edits.rs | 249 +++ tests/reliability.rs | 289 +++ tests/storage.rs | 272 +++ typst/shared/contract.typ | 54 +- typst/shared/modern.typ | 68 + typst/templates/atelier.typ | 12 + typst/templates/counsel.typ | 12 + typst/templates/folio.typ | 12 + 54 files changed, 6032 insertions(+), 664 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Cargo.lock create mode 100644 clauses/archive/consulting/standard-1.1.toml create mode 100644 clauses/archive/loan/standard-1.0.toml create mode 100644 clauses/archive/msa/standard-1.0.toml create mode 100644 clauses/archive/ncnda/standard-1.0.toml create mode 100644 clauses/archive/nda/standard-1.1.toml create mode 100644 clauses/archive/service/standard-1.0.toml create mode 100644 clauses/archive/sow/standard-1.0.toml create mode 100644 clauses/consulting/design.toml create mode 100644 clauses/consulting/technology.toml create mode 100644 clauses/msa/startup.toml create mode 100644 docs/LEGAL.md create mode 100644 scripts/smoke-pdfs.py create mode 100644 src/legal.rs create mode 100644 tests/draft_edits.rs create mode 100644 tests/reliability.rs create mode 100644 tests/storage.rs create mode 100644 typst/shared/modern.typ create mode 100644 typst/templates/atelier.typ create mode 100644 typst/templates/counsel.typ create mode 100644 typst/templates/folio.typ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f318c97..5669f72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,28 +5,46 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: test: runs-on: macos-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: contract-cli - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: paperfoot/finance-core + ref: d822761a58b1651add2b9eb4036cae87ffe5d5e7 path: finance-core token: ${{ secrets.FINANCE_CORE_TOKEN || github.token }} - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: contract-cli + - name: Format and lint + working-directory: contract-cli + run: | + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings - name: Tests working-directory: contract-cli - run: cargo test --release + run: cargo test --release --locked + - name: PDF content matrix + working-directory: contract-cli + run: | + brew install typst poppler + cargo build --release --locked + python3 scripts/smoke-pdfs.py --binary target/release/contract - name: Conformance (agent-cli-framework) working-directory: contract-cli run: | - curl -fsSL https://raw.githubusercontent.com/paperfoot/agent-cli-framework/main/conformance/conformance.sh -o /tmp/conformance.sh - chmod +x /tmp/conformance.sh - /tmp/conformance.sh ./target/release/contract + curl -fsSL https://raw.githubusercontent.com/paperfoot/agent-cli-framework/a7797eb3d1013d52830143e0f03e13d99b812bed/conformance/conformance.sh -o "$RUNNER_TEMP/conformance.sh" + chmod +x "$RUNNER_TEMP/conformance.sh" + "$RUNNER_TEMP/conformance.sh" ./target/release/contract diff --git a/.gitignore b/.gitignore index f8d14e7..338251d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ target/ -Cargo.lock .DS_Store *.pdf .ritalin/ .task-incomplete .claude/tsc-cache/ +work/ +*.db +*.db-* +.env* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2876fc0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 0.3.0 + +- Add explicit UK, US, Singapore and global legal profiles; validate state, governing law and venue choices. +- Add folio, counsel and atelier PDF designs, selectable A4/US Letter, embedded font fallbacks and semantic headings in the new designs. +- Add technology, design and startup clause packs. Refresh standard packs for liability, IP, confidentiality, notices, data processing and execution; retain the preceding pack versions in an archive. +- Snapshot selected clause templates and resolve historical contracts using their saved pack version. Reject clean output with unresolved variables. +- Fix the shared database's legacy contract-kind constraint so NCNDA and loan records can be created; preserve populated records, schema objects, sequences and foreign keys. Use distinct number prefixes for every kind. +- Reject invalid terms, contradictory disclosure settings, invalid dates, duration overflow and unsafe template/issuer names. Prevent metadata/clause edits after the first recorded signature and signatures after termination/expiry. +- Preserve wrapped clause/list paragraphs. Compile to a temporary destination and replace the output only on success. Share cached fonts with Typst instead of copying them into every render directory. Keep internal notes out of render data. +- Honour valid configured contract templates, sanitise default output filenames, and refresh automatic titles when duplicating for another party. +- Move to Rust 2024, declare Rust 1.88 minimum, refresh dependencies and track the lockfile. Add storage/lifecycle/profile regression tests, strict linting and a complete PDF content smoke matrix. +- Replace personal example references with fictional data and document legal-profile and signature-record limitations. + +Existing installed binaries are not updated by checking out this source. Build and install the desired revision explicitly. Keep your database backup and original signed PDFs when upgrading. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3a6896e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1984 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "contract-cli" +version = "0.3.0" +dependencies = [ + "anyhow", + "assert_cmd", + "chrono", + "clap", + "finance-core", + "predicates", + "rusqlite", + "rust-embed", + "rust_decimal", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "finance-core" +version = "0.4.0" +dependencies = [ + "chrono", + "directories", + "figment", + "refinery", + "rusqlite", + "rust_decimal", + "serde", + "serde_json", + "thiserror 2.0.20", + "toml 0.8.23", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.14+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "refinery" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba5d693abf62492c37268512ff35b77655d2e957ca53dab85bf993fe9172d15" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a83581f18c1a4c3a6ebd7a174bdc665f17f618d79f7edccb6a0ac67e660b319" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "serde", + "siphasher", + "thiserror 1.0.69", + "time", + "toml 0.8.23", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c225407d8e52ef8cf094393781ecda9a99d6544ec28d90a6915751de259264" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.119", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rusqlite" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" +dependencies = [ + "bitflags", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.14+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2195eec204e2764644a4ea619704f9fbe5e0673038eded55ad9956f24fca0cc" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 8b88965..844297f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "contract-cli" -version = "0.2.1" -edition = "2021" -description = "Beautiful contracts from the CLI — NDA, NCNDA, consulting, MSA, SOW, service, loan. Plain English, 1-3 pages, agent-friendly." +version = "0.3.0" +edition = "2024" +rust-version = "1.88" +description = "Beautiful contracts from the CLI — NDA, NCNDA, consulting, MSA, SOW, service, loan. Versioned clauses, jurisdiction profiles, agent-friendly." license = "MIT" repository = "https://github.com/paperfoot/contract-cli" readme = "README.md" @@ -21,7 +22,7 @@ finance-core = { path = "../finance-core", version = "0.4" } clap = { version = "4", features = ["derive", "env"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -toml = "0.8" +toml = "1.1.6" thiserror = "2" anyhow = "1" rusqlite = { version = "0.33", features = ["bundled", "chrono"] } diff --git a/README.md b/README.md index 68d6ec5..1dab774 100644 --- a/README.md +++ b/README.md @@ -1,193 +1,146 @@ # contract-cli -> Beautiful contracts from the CLI — NDA, NCNDA, consulting, MSA, SOW, service, loan. -> Plain English, 1–3 pages, agent-friendly. - -A stateful, single-binary CLI for drafting and rendering business contracts. -Built for humans who want a clean terminal workflow *and* for AI agents that -need a deterministic JSON interface to draft and dispatch contracts on -their owner's behalf. - -Sibling of [`invoice-cli`](https://github.com/paperfoot/invoice-cli) — same -SQLite store, same issuers, same clients. Drop a contract draft for a -client you already invoice, in one binary. - -## Features - -- **Seven contract kinds, plain English.** NDA (mutual or unilateral), - NCNDA (non-circumvention — protects introductions from being cut out), - consulting, MSA, SOW, service, loan. Embedded clause packs use - real-contract conventions: kind as title, project as subtitle, "Dated" - line, numbered `(1)/(2)` parties prose, "AGREED TERMS" section, discrete - boilerplate (notices, counterparts & e-signatures, third-party rights, - no-partnership, entire agreement). -- **Describe what you need — get the right kind.** `contract kinds find - "stop them going around me to steal my contact"` → `ncnda`. Deterministic - local scoring over per-kind trigger tags; ranked candidates, you pick. - No LLM, no network, no guessing. -- **Seven Typst templates, described and findable.** `contract template - list` shows each template's description, mood, tags, and fonts; - `contract template find "magazine masthead"` ranks them. The lineup: - - `helvetica-nera` — sober Swiss corporate instrument (default) - - `vienna-legal` — warm boutique, cream + terracotta - - `editorial` — formal serif, reads like a deed - - `gazette` — magazine masthead: Fraunces black over a broadsheet - dateline, literary Newsreader body - - `marrakech` — the Iowan / cream / terracotta data-room voice - - `basel` — Swiss brutalist grid with a marginalia rail - - `chancery` — engraved deed: letterspaced Garamond capitals, true - small caps, witness-ready execution -- **Premium fonts ship in the binary.** OFL faces (Fraunces, Newsreader, - Literata, Libre Franklin, Archivo, EB Garamond, Cormorant Garamond) - are embedded and passed to Typst via `--font-path`, so renders are - identical on any machine. System faces (Iowan Old Style, Helvetica - Neue) sit first in the stacks with embedded fallbacks. -- **Composable clause packs.** Include or exclude clauses at creation - time (`--include non_circumvention --exclude warranties`), override any - clause body from Markdown, add fully custom clauses. Arbitrary terms - flow into pack `{{vars}}` via `--term key=value` — e.g. - `--term principal_text='£10,000 (ten thousand pounds sterling)'`. -- **Signature lifecycle with teeth.** `draft → sent → signed → active → - expired / terminated`. Executed contracts only move forward; `sign` - refuses to overwrite a recorded signature without `--force`; drafts - render with a DRAFT watermark (`--final` for the signing copy); - sent/signed contracts lock clauses and metadata. -- **Agent-native, conformance-tested.** Full - [agent-cli-framework](https://github.com/paperfoot/agent-cli-framework) - compliance, verified in CI by the framework's conformance script: - canonical `agent-info` manifest with per-command arg/option schemas, - JSON envelope on every code path (piped `--help` included), semantic - exit codes 0–4, tested error suggestions, distribution-aware `update`, - `skill install|status` for Claude / Codex / Gemini. - -## Install - -### Homebrew (macOS / Linux) +Draft business contracts and render them as carefully typeset PDFs. A local Rust CLI with versioned clause packs, selectable governing law, and a predictable JSON interface for agents. -``` -brew tap paperfoot/tap -brew install contract -``` +Shares issuers and clients with [invoice-cli](https://github.com/paperfoot/invoice-cli) through [finance-core](https://github.com/paperfoot/finance-core). -### Cargo +## What is included -``` -cargo install contract-cli +- Seven kinds: NDA, NCNDA, consulting, MSA, SOW, service and loan. +- Ten PDF templates, with A4 and US Letter output. New **folio** for technology and startups, **counsel** for restrained legal typography, and **atelier** for design engagements. +- Standard clause packs plus `consulting/technology`, `consulting/design` and `msa/startup`. +- Explicit `global`, `uk`, `us` and `singapore` legal profiles. +- Draft editing, clause composition, lifecycle controls and administrative signature records. +- Local discovery with `kinds find` and `template find`; no model or network needed. +- JSON output when piped, semantic exit codes, and a capability manifest via `agent-info`. + +## Build and install + +Rust 1.88 or newer and Typst are required to build and render. The current dependencies are locked in `Cargo.lock`. + +```sh +brew install typst +# Keep these repositories next to one another: +git clone https://github.com/paperfoot/finance-core.git +git clone https://github.com/paperfoot/contract-cli.git +cd contract-cli +cargo install --path . --locked +contract --version ``` -All install paths produce a single `contract` binary. Typst is the only -runtime dependency (`brew install typst`). +The source build uses the sibling `finance-core` checkout. CI pins that dependency revision. The existing `paperfoot/tap` Homebrew distribution may lag the source branch; check the installed version before relying on new flags. ## Quick start +All entities below are fictional. Check `issuer list` and `clients list` first: their records are shared with invoice-cli. + ```sh -contract issuer list # shared with invoice-cli +contract issuer list contract clients list -# Don't remember the kind or template names? Describe them: -contract kinds find "they can't bypass me and deal direct" -contract template find "warm cream editorial" - -# Quick mutual NDA — 3-year term -contract new --kind nda --as boris --client reshape-clinic \ - --purpose "exploring a joint product line" --term-years 3 +# Only create these if they do not already exist. +contract issuer add acme --name "Acme Example Ltd" --jurisdiction uk \ + --address '1 Example Street\nLondon' --email contracts@acme.example +contract clients add meridian --name "Meridian Example Ltd" \ + --address '2 Example Avenue\nLondon' --email legal@meridian.example + +contract new --kind consulting --as acme --client meridian \ + --legal-profile uk --pack technology --template folio \ + --purpose "Build the customer dashboard" --term-months 3 \ + --fee fixed:8400:GBP --fee-schedule on-completion \ + --deliverable "Dashboard source code and build instructions" \ + --deliverable "Deployment and one handover session" \ + --term acceptance_days=10 + +# Use the number returned by new. Draft PDFs carry a DRAFT watermark. +contract render CTR-acme-2026-0001 --open +# A clean signing copy; this does not send or sign the document. +contract render CTR-acme-2026-0001 --final --paper us-letter +``` -# Non-circumvention agreement protecting an introduction -contract new --kind ncnda --as boris --client partner \ - --purpose "introduction to prospective lenders in connection with the transaction" \ - --term-years 2 --governing-law "England and Wales" +## Choose the governing law -# Interest-free loan, fixed repayment date -contract new --kind loan --as boris --client friend \ - --term principal_text='£10,000 (ten thousand pounds sterling)' \ - --term interest_text='interest-free' \ - --term repayment_date=2026-12-01 +| Profile | Selection | Scope | +|---|---|---| +| UK | `--legal-profile uk` | Defaults to England and Wales; `--governing-law Scotland` or `"Northern Ireland"` also accepted. | +| US | `--legal-profile us --us-state Delaware` | Requires a full state name or District of Columbia. Adds a federal trade-secret immunity notice to current packs. | +| Singapore | `--legal-profile singapore` | Singapore governing law and courts unless an explicit venue is supplied. | +| Global | `--legal-profile global --governing-law Germany --venue "the courts of Berlin, Germany"` | A cross-border starting point requiring an explicit law and court venue. | -# Render — DRAFT watermark by default; --final removes it -contract render NDA-boris-2026-0001 --template marrakech --final --open +`--venue` is the complete court phrase inserted after “exclusive jurisdiction of”. It selects courts, not arbitration. Profiles select law, venue wording and limited notices; they do not provide a complete jurisdiction-specific legal adaptation. “Global” is not a governing law. See [legal scope and sources](docs/LEGAL.md). -# Record signatures — status auto-bumps to "signed" when both sides sign -contract sign NDA-boris-2026-0001 --side us --name "B. Djordjevic" --title "Director" -contract sign NDA-boris-2026-0001 --side them --name "A. Pertusa" -``` +Without a profile, `--governing-law` remains available. An omitted law falls back to the issuer's jurisdiction (UK resolves to England and Wales); an ambiguous US or EU default requires a specific law. Select a profile explicitly for new work. Editing law must remain consistent with the stored profile. -## Core commands +## Choose wording and design separately -| Command | Purpose | +| Clause pack | Additional provisions | |---|---| -| `issuer add\|edit\|list\|show\|delete` | Manage issuers (your side; shared with invoice-cli) | -| `clients add\|edit\|list\|show\|delete` | Manage counterparties (shared with invoice-cli) | -| `new --kind --as --client [options…]` | Create a contract (`--term key=value` for kind-specific terms) | -| `list \| show \| edit \| duplicate \| delete` | Work the contract book (all top-level) | -| `render [--template] [--out] [--open] [--final \| --draft]` | Generate the PDF | -| `mark ` | Lifecycle moves (forward-only once executed) | -| `sign --side us\|them --name "…"` | Record a signature (`--force` to overwrite) | -| `contracts clauses list\|add\|edit\|remove\|move\|reset` | Compose the clause set | -| `pack list \| show ` | Browse clause packs | -| `template list \| find "" \| preview ` | Discover and preview templates | -| `kinds list \| find ""` | Discover contract kinds | -| `doctor` | Verify typst + DB + packs + shared issuers/clients | -| `agent-info` | Canonical JSON capability manifest | -| `skill install \| status` | Manage the embedded agent skill | -| `update [--check]` | Distribution-aware update (brew / cargo) | - -Run `contract --help` for Tips and Examples. - -## Template resolution +| `consulting/technology` | Objective acceptance, secure development, source and build handover, dependency licensing and AI use. | +| `consulting/design` | Revision allowance, source files, third-party assets, font licences and express portfolio permission. | +| `msa/startup` | Signed SOWs, continuity and exit handover; no implied equity or fundraising commitment. | -``` ---template flag > contract.default_template > "helvetica-nera" +```sh +contract pack show consulting --pack design +contract template preview folio --kind consulting --pack technology --out ./technology.pdf +contract template preview counsel --kind nda --out ./nda.pdf +contract template preview atelier --kind consulting --pack design --out ./design.pdf +contract template find "quiet legal serif" ``` -Templates are validated when set, not just at render. Every template -carries a `//!` metadata header (description, mood, tags, fonts, paper) — -drop your own `.typ` in the extracted templates dir with the same header -and it participates in `template list` / `template find` immediately. +The original `helvetica-nera`, `vienna-legal`, `editorial`, `gazette`, `marrakech`, `basel` and `chancery` designs remain available. The default remains `helvetica-nera`. -## Composing clauses +Template resolution is `--template`, then the contract's stored template, then a valid shared config template, then `helvetica-nera`. A shared invoice-only template is skipped. `config set default_template folio` changes the shared accounting configuration. Embedded fonts carry OFL licences; older designs may use system fonts before their embedded fallbacks, so appearance can differ across machines. +## Compose clauses + +```sh +contract pack show nda +contract contracts clauses list NDA-acme-2026-0001 +contract contracts clauses edit NDA-acme-2026-0001 purpose --from-file ./purpose.md +contract contracts clauses add NDA-acme-2026-0001 custom --heading "Project requirements" --body "Agreed wording." +contract contracts clauses reset NDA-acme-2026-0001 ``` -contract pack show ncnda -contract contracts clauses add NCNDA-boris-2026-0001 non_solicit --position 8 -contract contracts clauses edit NCNDA-boris-2026-0001 termination --from-file ./ours.md -contract contracts clauses reset NCNDA-boris-2026-0001 -``` -Clause bodies use simple Markdown — paragraphs, dash bullets, `1.` -numbered lists. Pack clauses substitute `{{vars}}`: the built-ins -(`{{our_legal_name}}`, `{{effective_date}}`, `{{term_text}}`, -`{{governing_law}}`, `{{jurisdiction_phrase}}`, `{{fee_text}}`, -`{{deliverables_block}}`, `{{purpose}}`, …) plus any scalar you set via -`--term key=value`. +Bodies support plain paragraphs, dash bullets, numbered lists and wrapped list items. Clause text is treated as text, not executable Typst. Pack variables use `{{name}}`; scalar terms can be supplied with repeatable `--term key=value`. Recognised numeric and choice terms are validated. `legal_profile` is reserved for `--legal-profile`. + +A SOW needs `--term msa_reference="MSA number and date"`. Loan terms include `principal_text`, `interest_text` and `repayment_date`. Supply complete terms and review the rendered agreement: unresolved variables block a clean render, but the CLI does not establish commercial completeness or enforceability. -## State & privacy +New contracts snapshot selected clause templates and store the pack version. Archived standard packs preserve the immediately preceding shipped versions. Older contracts resolve against their stored version; an unavailable version fails explicitly. Reset uses that same version. Duplicate retains the source wording, law, venue and terms, clears signatures and the absolute end date, and refreshes an automatically generated title for new parties. Review copied dates embedded in free-form terms. -- **Config:** `~/Library/Application Support/com.paperfoot.accounting/config.toml` - (macOS), env overrides via `PAPERFOOT_*`. -- **Database:** shared SQLite `accounting.db` in the same dir. -- **Templates & fonts:** extracted to the shared assets dir on first use; - refreshed on upgrade. +## Lifecycle and signatures -Nothing ever leaves your machine. No telemetry. No phone-home. +`draft → sent → signed → active → expired / terminated` -## Architecture +Sent contracts lock metadata and clauses. An unsigned sent contract can return to draft. Recording even one signature locks edits and prevents recall. Executed contracts cannot return to an editable state. Terminated and expired contracts cannot receive signatures. + +```sh +contract mark NDA-acme-2026-0001 sent +# Record details only after signing has happened through the agreed process. +contract sign NDA-acme-2026-0001 --side us --name "Alex Morgan" --title Director +contract sign NDA-acme-2026-0001 --side them --name "Sam Taylor" --title Director +``` -- **Rust** single binary; **SQLite** via `rusqlite` + `refinery` - migrations (shared [`finance-core`](https://github.com/paperfoot/finance-core)). -- **Typst** renders the PDF — templates + OFL fonts embedded via - `rust-embed`, JSON sidecar pattern, `typst compile --font-path`. -- **Clause packs** as TOML, embedded. Seven kinds × `standard` pack. -- Follows [`agent-cli-framework`](https://github.com/paperfoot/agent-cli-framework); - the conformance script runs in CI. +`sign` records names and dates; it does not authenticate people, obtain consent, send a document, cryptographically sign a PDF, or provide an e-signature audit trail. `mark signed` is an administrative assertion. Retain the actual executed PDF and its evidence separately. Rerendering is not archival reproduction: party records, formatting and software can change. -## Scope +## State and privacy + +Config and SQLite state live in the accounting suite's platform-specific application directory; use `config path` and `agent-info` to inspect the locations. Templates and fonts are cached in its assets directory. + +Drafting and rendering are local. There is no telemetry or document upload. Explicit update commands use the network; `--open` launches the local PDF viewer. The database, exported PDFs, command JSON and shell history can contain party information. Internal contract notes are excluded from the rendering sidecar and PDF. Database files, environment files and generated PDFs are ignored by Git. This repository's examples use fictional entities and `.example` email addresses. + +## Development + +```sh +cargo fmt --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +cargo build --locked +# Requires Typst and Poppler (brew install typst poppler). +python3 scripts/smoke-pdfs.py --binary target/debug/contract +``` -A **contract drafting tool**, not legal advice. Out of scope for now: -negotiation/redline workflow, e-signature platforms, contracts with more -than two parties (multi-party NCNDA/JV support is planned — the data -model groundwork exists), M&A/court/regulatory documents. Have a lawyer -review anything material. +The PDF smoke check renders every template/kind combination and the specialised packs, verifies actual clause text and unresolved variables, and checks US Letter dimensions when `pdfinfo` is available. A long custom-document case also checks wrapped lists, private-note exclusion, missing-term rejection and preservation of an existing PDF after compiler failure. Tests isolate HOME and XDG state. The migration tests preserve populated legacy records, custom indexes/triggers, foreign keys and sequence values. -## License +See [changes](CHANGELOG.md) and [legal scope](docs/LEGAL.md). These are drafting starting points for two-party business agreements, not a substitute for legal advice or prescribed regulated documents. MIT © 199 Biotechnologies diff --git a/clauses/archive/consulting/standard-1.1.toml b/clauses/archive/consulting/standard-1.1.toml new file mode 100644 index 0000000..aa8c33a --- /dev/null +++ b/clauses/archive/consulting/standard-1.1.toml @@ -0,0 +1,107 @@ +[pack] +slug = "standard" +name = "Standard Consulting Agreement (plain English)" +version = "1.1" +kind = "consulting" +default_clauses = [ + "background", + "services", + "deliverables", + "fees_and_expenses", + "timing", + "relationship", + "ip_ownership", + "confidentiality", + "warranties", + "liability", + "termination", + "general", +] + +[clauses.background] +heading = "Background" +body = """ +{{our_legal_name}} (the “Consultant”) provides advisory and delivery services. {{their_legal_name}} (the “Client”) wishes to engage the Consultant in connection with {{purpose}}. This agreement sets out the terms. +""" + +[clauses.services] +heading = "Services" +body = """ +The Consultant will provide the services described under “Deliverables” below, together with such ancillary work as the parties reasonably agree from time to time (the “Services”). The Consultant will perform the Services with reasonable skill and care, and will keep the Client informed of progress. +""" + +[clauses.deliverables] +heading = "Deliverables" +body = """ +The Consultant will deliver the following: + +{{deliverables_block}} + +If the Client wants something materially outside this list, the parties will agree the change — and any impact on fees or timing — in writing (email is fine) before the additional work begins. +""" + +[clauses.fees_and_expenses] +heading = "Fees and Expenses" +body = """ +The Client will pay the Consultant {{fee_text}}. + +Invoices are payable within 14 days of receipt. Late amounts may accrue interest at 1% per month from the due date. Reasonable out-of-pocket expenses (travel, materials, third-party software) are reimbursable when pre-approved in writing. +""" + +[clauses.timing] +heading = "Timing" +body = """ +The engagement begins on {{effective_date}} and runs {{term_text}}. The parties will work in good faith to meet any milestones agreed in writing. Delays caused primarily by the Client (late feedback, missing inputs, scope changes) extend the relevant dates by an equivalent amount and do not change the fee. +""" + +[clauses.relationship] +heading = "Independent Contractor" +body = """ +The Consultant is an independent contractor, not an employee, agent, partner, or joint-venture partner of the Client. Nothing in this agreement creates such a relationship. The Consultant is responsible for its own taxes, social contributions, and insurance, and may engage subcontractors provided the Consultant remains responsible for their work. +""" + +[clauses.ip_ownership] +heading = "Intellectual Property" +body = """ +{{ip_assignment_text}} + +The Consultant keeps ownership of any tools, libraries, methods, frameworks, or general know-how it had before, or developed independently of, this engagement (the Consultant’s “Background IP”). To the extent any Background IP is incorporated into a Deliverable, the Consultant grants the Client a non-exclusive, perpetual, royalty-free licence to use it as part of that Deliverable. +""" + +[clauses.confidentiality] +heading = "Confidentiality" +body = """ +Each party will treat the other’s non-public information (including business plans, customer data, financials, and technical materials) with reasonable care, use it only for the engagement, and share it inside its organisation only on a need-to-know basis. This obligation survives for {{confidentiality_years}} years after the engagement ends. It does not apply to information that becomes public other than through that party’s fault, was already known on a non-confidential basis, or is independently developed. +""" + +[clauses.warranties] +heading = "Warranties" +body = """ +The Consultant warrants that it will perform the Services with reasonable skill and care, in line with industry standards, and that to the best of its knowledge the Deliverables will not knowingly infringe any third-party intellectual property. The Consultant does not warrant any specific business outcome. The Client warrants that it has the authority to enter into this agreement and to provide any materials it shares with the Consultant. +""" + +[clauses.liability] +heading = "Limitation of Liability" +body = """ +Neither party is liable to the other for indirect, consequential, or loss-of-profits damages. Each party’s total liability under this agreement is capped at the fees paid or payable to the Consultant under this agreement in the twelve months before the event giving rise to the claim. Nothing in this clause limits liability that cannot be limited under applicable law, including liability for fraud or willful misconduct. +""" + +[clauses.termination] +heading = "Termination" +body = """ +Either party may terminate this agreement for convenience by giving the other at least {{termination_notice_days}} days’ written notice. Either party may terminate immediately for material breach that is not cured within 15 days of written notice describing it, or if the other party becomes insolvent. On termination, the Client will pay the Consultant for Services performed up to the termination date and for non-cancellable expenses. Clauses that by their nature should survive (confidentiality, IP, liability, governing law) survive termination. +""" + +[clauses.general] +heading = "General" +body = """ +This agreement is the whole agreement on its subject and replaces prior discussions. Changes must be in writing signed by both parties (email exchange is fine where the parties clearly confirm the change). If any part is unenforceable, the rest stays in force. Neither party may assign without the other’s consent, except to an affiliate or successor in connection with a sale of its business. Notices may be given by email to the address on the signature page. This agreement is governed by the laws of {{governing_law}} and the parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. +""" + +# Optional clauses — not in the default set; add with --include. + +[clauses.non_solicit] +heading = "Non-Solicitation" +body = """ +While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact under this engagement. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. +""" diff --git a/clauses/archive/loan/standard-1.0.toml b/clauses/archive/loan/standard-1.0.toml new file mode 100644 index 0000000..8492a00 --- /dev/null +++ b/clauses/archive/loan/standard-1.0.toml @@ -0,0 +1,118 @@ +[pack] +slug = "standard" +name = "Standard Loan Agreement (plain English)" +version = "1.0" +kind = "loan" +default_clauses = [ + "the_loan", + "advance", + "interest", + "repayment", + "prepayment", + "default", + "set_off", + "costs", + "assignment", + "notices", + "no_partnership", + "third_party_rights", + "entire_agreement", + "counterparts", + "governing_law", +] + +[clauses.the_loan] +heading = "The Loan" +body = """ +{{our_legal_name}} (the “Lender”) agrees to lend {{their_legal_name}} (the “Borrower”) {{principal_text}} (the “Loan”). If no amount is stated above, the “Loan” means the total amount the Lender actually advances to the Borrower under this agreement. +""" + +[clauses.advance] +heading = "Advance" +body = """ +The Lender will advance the Loan in a single payment, by bank transfer to an account the Borrower nominates in writing (email is fine), on or shortly after {{effective_date}}. If the parties agree in writing to advance the Loan in instalments, each instalment forms part of the Loan from the date it is paid. +""" + +[clauses.interest] +heading = "Interest" +body = """ +The Loan is made on the following interest terms: {{interest_text}}. If no interest terms are stated, the Loan is interest-free. Any interest that applies accrues only on the amount of the Loan outstanding from time to time and is payable when the Loan is repaid, unless the parties agree otherwise in writing. +""" + +[clauses.repayment] +heading = "Repayment" +body = """ +The Borrower will repay the Loan in full, together with any accrued interest, on or before {{repayment_date}} (the “Repayment Date”), by bank transfer to an account the Lender nominates in writing (email is fine). If no Repayment Date is stated, the Loan is repayable on demand: the Borrower will repay it in full within 30 days after the Lender demands repayment in writing. +""" + +[clauses.prepayment] +heading = "Prepayment" +body = """ +The Borrower may repay the Loan early, in whole or in part, at any time and without penalty. Early part-payments reduce the outstanding balance immediately. +""" + +[clauses.default] +heading = "Default and Acceleration" +body = """ +Each of the following is an “Event of Default”: + +- the Borrower fails to pay any amount due under this agreement within 5 business days of its due date; +- the Borrower materially breaches this agreement and does not remedy the breach within 15 days of written notice describing it; or +- the Borrower becomes insolvent, enters bankruptcy or any similar procedure, or stops paying debts as they fall due. + +If an Event of Default occurs, the Lender may, by written notice, declare the whole outstanding Loan (with any accrued interest) immediately due and payable, and may recover it as a debt. +""" + +[clauses.set_off] +heading = "Payments Without Set-Off" +body = """ +All payments by the Borrower under this agreement must be made in full, without set-off, counterclaim, deduction, or withholding, except for any deduction or withholding required by law. +""" + +[clauses.costs] +heading = "Costs" +body = """ +Each party bears its own costs of preparing and entering into this agreement. After an Event of Default, the Borrower will also pay the Lender’s reasonable costs of recovering the Loan, including legal costs. +""" + +[clauses.assignment] +heading = "Assignment" +body = """ +The Borrower may not assign or transfer any of its rights or obligations under this agreement. The Lender may assign its right to repayment on written notice to the Borrower. +""" + +[clauses.notices] +heading = "Notices" +body = """ +Notices under this agreement must be in writing and sent to the address or email of the relevant party on the signature page, or as later notified in writing. +""" + +[clauses.no_partnership] +heading = "No Partnership" +body = """ +Nothing in this agreement creates a partnership, joint venture, agency, or employment relationship between the parties, and the Lender owes the Borrower no duties as an advisor or fiduciary. +""" + +[clauses.third_party_rights] +heading = "Third-Party Rights" +body = """ +A person who is not a party to this agreement has no right to enforce any of its terms, whether under the Contracts (Rights of Third Parties) Act 1999 or otherwise. +""" + +[clauses.entire_agreement] +heading = "Entire Agreement" +body = """ +This agreement is the entire agreement between the parties on the Loan and replaces any prior discussions or understandings on the same subject. Changes must be in writing and signed by both parties. +""" + +[clauses.counterparts] +heading = "Counterparts and Electronic Signatures" +body = """ +This agreement may be signed in any number of counterparts, each of which is an original and which together form one agreement. Signatures exchanged electronically — including scanned and e-signed copies — are valid and binding. +""" + +[clauses.governing_law] +heading = "Governing Law and Jurisdiction" +body = """ +This agreement, and any dispute arising out of or in connection with it, are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. +""" diff --git a/clauses/archive/msa/standard-1.0.toml b/clauses/archive/msa/standard-1.0.toml new file mode 100644 index 0000000..6c7cc1c --- /dev/null +++ b/clauses/archive/msa/standard-1.0.toml @@ -0,0 +1,79 @@ +[pack] +slug = "standard" +name = "Standard Master Services Agreement (plain English)" +version = "1.0" +kind = "msa" +default_clauses = [ + "background", + "services_via_sow", + "fees_general", + "term", + "confidentiality", + "ip_ownership", + "warranties", + "liability", + "termination", + "general", +] + +[clauses.background] +heading = "Background" +body = """ +{{our_legal_name}} (the “Provider”) provides professional services to clients. {{their_legal_name}} (the “Client”) wishes to engage the Provider on multiple projects over time without renegotiating common terms each time. This agreement sets out those common terms; specific projects are described in separate Statements of Work (each, an “SOW”). +""" + +[clauses.services_via_sow] +heading = "Services Under SOWs" +body = """ +From time to time, the parties may sign one or more SOWs that reference this agreement. Each SOW describes the services, deliverables, fees, timing, and any project-specific terms. Each signed SOW is incorporated into this agreement. If there is a conflict between this agreement and an SOW, the SOW controls for that project unless the SOW says otherwise. +""" + +[clauses.fees_general] +heading = "Fees and Expenses (General)" +body = """ +Fees, payment schedules, and expense rules are set in each SOW. Unless an SOW says otherwise, invoices are payable within 14 days of receipt and reasonable pre-approved out-of-pocket expenses are reimbursable. Taxes are charged in addition to fees where the Provider is required to charge them. +""" + +[clauses.term] +heading = "Term" +body = """ +This agreement takes effect on {{effective_date}} and continues {{term_text}}. Either party may terminate this agreement when no SOWs are in effect by giving the other 30 days’ written notice. Termination of this agreement does not by itself terminate any in-flight SOW; in-flight SOWs continue to be governed by this agreement until they end. +""" + +[clauses.confidentiality] +heading = "Confidentiality" +body = """ +Each party will treat the other’s non-public information (including business plans, customer data, financials, and technical materials) with reasonable care, use it only for the engagement, and share it inside its organisation only on a need-to-know basis. This obligation survives {{confidentiality_years}} years after the engagement ends, and does not apply to information that becomes public other than through that party’s fault, was already known on a non-confidential basis, or is independently developed. +""" + +[clauses.ip_ownership] +heading = "Intellectual Property" +body = """ +{{ip_assignment_text}} + +The Provider keeps ownership of any tools, libraries, methods, frameworks, or general know-how it had before, or developed independently of, the engagement (the Provider’s “Background IP”). To the extent any Background IP is incorporated into a Deliverable, the Provider grants the Client a non-exclusive, perpetual, royalty-free licence to use it as part of that Deliverable. +""" + +[clauses.warranties] +heading = "Warranties" +body = """ +The Provider warrants that it will perform the services with reasonable skill and care, in line with industry standards, and that to the best of its knowledge the deliverables will not knowingly infringe any third-party intellectual property. The Provider does not warrant any specific business outcome. +""" + +[clauses.liability] +heading = "Limitation of Liability" +body = """ +Neither party is liable to the other for indirect, consequential, or loss-of-profits damages. Each party’s total liability under any SOW is capped at the fees paid or payable under that SOW in the twelve months before the event giving rise to the claim. Nothing in this clause limits liability that cannot be limited under applicable law, including liability for fraud or willful misconduct. +""" + +[clauses.termination] +heading = "Termination of an SOW" +body = """ +Either party may terminate an SOW immediately for material breach that is not cured within 15 days of written notice, or if the other party becomes insolvent. Either party may terminate an SOW for convenience by giving the other at least {{termination_notice_days}} days’ written notice, unless the SOW says otherwise. On termination of an SOW, the Client will pay the Provider for services performed up to the termination date and for non-cancellable expenses. +""" + +[clauses.general] +heading = "General" +body = """ +This agreement (together with any SOWs) is the whole agreement on its subject and replaces prior discussions. Changes must be in writing signed by both parties. If any part is unenforceable, the rest stays in force. Neither party may assign without the other’s consent, except to an affiliate or successor in connection with a sale of its business. Notices may be given by email to the address on the signature page. This agreement is governed by the laws of {{governing_law}} and the parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. +""" diff --git a/clauses/archive/ncnda/standard-1.0.toml b/clauses/archive/ncnda/standard-1.0.toml new file mode 100644 index 0000000..4f9e2eb --- /dev/null +++ b/clauses/archive/ncnda/standard-1.0.toml @@ -0,0 +1,162 @@ +[pack] +slug = "standard" +name = "Standard Non-Circumvention & Non-Disclosure Agreement (plain English)" +version = "1.0" +kind = "ncnda" +default_clauses = [ + "purpose", + "definitions", + "confidentiality", + "permitted_disclosures", + "non_circumvention", + "introducer_role", + "prior_dealings", + "remedies", + "no_obligation", + "term", + "entire_agreement", + "variation", + "assignment", + "severance", + "third_party_rights", + "notices", + "no_partnership", + "counterparts", + "governing_law", +] + +[clauses.purpose] +heading = "Purpose" +body = """ +{{our_legal_name}} (“Party A”) and {{their_legal_name}} (“Party B”) are working together in connection with {{purpose}} (the “Purpose”). For the Purpose, the parties expect to share information that is not public and to introduce one another to contacts, counterparties, opportunities, and sources of funds. This agreement protects that information and ensures that neither party is cut out of the introductions made under it. +""" + +[clauses.definitions] +heading = "Definitions" +body = """ +“Confidential Information” means any non-public information one party (the “Disclosing Party”) makes available to the other (the “Receiving Party”) in connection with the Purpose, in any form and whether or not marked confidential. It includes the existence and content of the parties’ discussions, the identity and contact details of any person or entity introduced under this agreement, the terms of any proposed transaction, and any analysis, copy, or derivative of any of the foregoing. + +“Introduction” means any introduction, direct or indirect, by one party to the other of a person, entity, asset, transaction, opportunity, or source of funds not already known to the receiving party on a non-confidential basis. + +“Protected Contact” means any person, entity, asset, transaction, opportunity, or source of funds first made known to a party by or through the other party under or in connection with this agreement. + +“Representatives” means, for each party, its directors, officers, employees, professional advisors, and agents. +""" + +[clauses.confidentiality] +heading = "Confidentiality" +body = """ +Each Receiving Party will: + +- keep the Confidential Information secret and secure, and use it only for the Purpose; +- not disclose it to anyone except as permitted under “Permitted Disclosures”; +- not copy or record it except as necessary for the Purpose; and +- protect it with no less than reasonable care. + +Neither party will make any public statement about the Purpose, this agreement, or the other party’s involvement without that party’s prior written consent. +""" + +[clauses.permitted_disclosures] +heading = "Permitted Disclosures" +body = """ +A Receiving Party may disclose Confidential Information: + +1. to those of its Representatives who need to know it for the Purpose, provided they are bound by confidentiality obligations at least as protective as this agreement — the Receiving Party remains responsible for their compliance; and +2. to the extent required by law, regulation, or a court or competent authority, provided that (where lawful and practicable) it gives the Disclosing Party prompt prior notice and discloses only what is required. +""" + +[clauses.non_circumvention] +heading = "Non-Circumvention" +body = """ +Neither party will, directly or indirectly, use any Confidential Information or any Introduction to circumvent, bypass, or exclude the party by or through whom a Protected Contact was introduced, in relation to any transaction arising from or materially connected with the Purpose and that Protected Contact. This includes soliciting, negotiating, arranging, brokering, concluding, or facilitating such a transaction without the introducing party’s prior written agreement, or otherwise than in a way that preserves the introducing party’s role and any remuneration separately agreed with it. Each party is responsible for any breach of this clause by its Representatives. + +This obligation lasts for {{non_circumvention_months}} months from the date of this agreement; if no period is stated, it lasts for the term of this agreement. +""" + +[clauses.introducer_role] +heading = "Introductions and Remuneration" +body = """ +Each party acknowledges that the Introductions the other makes have commercial value. A party that benefits from an Introduction will not conclude or pursue any transaction arising from it in a way that excludes the introducing party from its role as introducer or from any fee, commission, or other remuneration separately agreed with it. + +The parties record the agreed remuneration for Introductions as follows: {{commission_text}}. If none is recorded, remuneration (if any) remains as separately agreed in writing, and the absence of an agreed fee does not weaken the non-circumvention obligations in this agreement. +""" + +[clauses.prior_dealings] +heading = "Prior and Independent Contacts" +body = """ +Nothing in this agreement prevents a party from dealing with a contact or opportunity that it can show, by written evidence, it already knew of — or was already dealing with independently — before the relevant Introduction, or that it lawfully obtains from a source other than the other party and without breach of this agreement. The burden of proving prior or independent knowledge rests on the party claiming it. +""" + +[clauses.remedies] +heading = "Remedies" +body = """ +Both parties acknowledge that damages alone may not be an adequate remedy for a breach of this agreement. The injured party may seek injunctive relief and specific performance in any court of competent jurisdiction — notwithstanding the exclusive jurisdiction clause below — in addition to any other remedy. + +Without limiting any other remedy, if a party concludes or facilitates a transaction in breach of the non-circumvention clause, the circumvented party may recover its proven loss from that breach, including any fee, commission, or other remuneration it can prove would, on the balance of probabilities, have been earned had that clause been complied with. There is no double recovery, and nothing in this clause limits any equitable remedy available. +""" + +[clauses.no_obligation] +heading = "No Commitment, No Licence" +body = """ +Neither party is obliged by this agreement to proceed with the Purpose or any transaction, or to share any particular information; either party may end discussions at any time. All Confidential Information remains the property of the Disclosing Party. No licence or other right is granted beyond the limited right to use it for the Purpose. It is provided “as is”, without warranty as to accuracy or completeness. +""" + +[clauses.term] +heading = "Term and Survival" +body = """ +This agreement takes effect on {{effective_date}} and continues {{term_text}}. The confidentiality obligations survive for {{confidentiality_years}} years after this agreement ends, and indefinitely for any information that qualifies as a trade secret under applicable law. The non-circumvention obligations survive in accordance with their terms. +""" + +[clauses.entire_agreement] +heading = "Entire Agreement" +body = """ +This agreement is the entire agreement between the parties on its subject and replaces any prior discussions or arrangements. Neither party has relied on any statement not set out in it. +""" + +[clauses.variation] +heading = "Variation and Waiver" +body = """ +Changes to this agreement must be in writing and signed by both parties. A failure or delay in exercising a right is not a waiver of it. +""" + +[clauses.assignment] +heading = "Assignment" +body = """ +Neither party may assign or transfer any of its rights or obligations under this agreement without the other party’s prior written consent. +""" + +[clauses.severance] +heading = "Severance" +body = """ +If any provision of this agreement is or becomes invalid or unenforceable, it is deemed deleted and the rest stays in force — provided the deletion does not materially change the agreement’s overall effect. +""" + +[clauses.third_party_rights] +heading = "Third-Party Rights" +body = """ +A person who is not a party to this agreement has no right to enforce any of its terms, whether under the Contracts (Rights of Third Parties) Act 1999 or otherwise. +""" + +[clauses.notices] +heading = "Notices" +body = """ +Notices under this agreement must be in writing and sent to the address or email of the relevant party on the signature page, or as later notified in writing. +""" + +[clauses.no_partnership] +heading = "No Partnership" +body = """ +Nothing in this agreement creates a partnership, joint venture, agency, or employment relationship between the parties. +""" + +[clauses.counterparts] +heading = "Counterparts and Electronic Signatures" +body = """ +This agreement may be signed in any number of counterparts, each of which is an original and which together form one agreement. Signatures exchanged electronically — including scanned and e-signed copies — are valid and binding. +""" + +[clauses.governing_law] +heading = "Governing Law and Jurisdiction" +body = """ +This agreement, and any non-contractual obligations arising out of or in connection with it, are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, except that either party may seek injunctive or protective relief in any court of competent jurisdiction. +""" diff --git a/clauses/archive/nda/standard-1.1.toml b/clauses/archive/nda/standard-1.1.toml new file mode 100644 index 0000000..3456fab --- /dev/null +++ b/clauses/archive/nda/standard-1.1.toml @@ -0,0 +1,166 @@ +[pack] +slug = "standard" +name = "Standard NDA (plain English)" +version = "1.1" +kind = "nda" +default_clauses = [ + "purpose", + "definitions", + "obligations", + "exclusions", + "permitted_disclosures", + "term", + "return_or_destroy", + "no_license", + "no_obligation", + "remedies", + "entire_agreement", + "variation", + "severance", + "assignment", + "notices", + "counterparts", + "third_party_rights", + "no_partnership", + "governing_law", +] + +[clauses.purpose] +heading = "Purpose" +body = """ +{{our_legal_name}} (“{{our_name}}”) and {{their_legal_name}} (“{{their_name}}”) wish to explore {{purpose}}. To have a useful conversation, the parties may need to share information that is not public. This agreement sets out how that information must be handled. +""" + +[clauses.definitions] +heading = "Confidential Information" +body = """ +“Confidential Information” means any information — technical, commercial, financial, or otherwise — that one party makes available to the other in connection with the Purpose, whether shared in writing, verbally, or by any other means, and whether or not marked “confidential”. It includes information about products, research, methods, customers, finances, plans, and personnel. +""" + +[clauses.obligations] +heading = "How the Information Must Be Treated" +body = """ +The receiving party will: + +- use the Confidential Information only for the Purpose; +- protect it with at least the same care it uses for its own confidential information of similar importance, and never less than reasonable care; +- limit access to the people who actually need to know it for the Purpose; and +- not copy, reproduce, or distribute it except as necessary for the Purpose. +""" + +[clauses.exclusions] +heading = "What Is Not Covered" +body = """ +These obligations do not apply to information that: + +1. is, or becomes, publicly available through no fault of the receiving party; +2. the receiving party already had on a non-confidential basis before it was shared; +3. is independently developed by the receiving party without using the Confidential Information; or +4. is rightfully received from a third party with no duty of confidence. + +If a court, regulator, or law forces disclosure, the receiving party may comply, but will give the disclosing party prompt notice where it is lawful to do so, and disclose only what is required. +""" + +[clauses.permitted_disclosures] +heading = "Sharing With Advisors" +body = """ +The receiving party may share the Confidential Information with its directors, employees, and professional advisors (such as lawyers and accountants) on a need-to-know basis, provided those people are bound by obligations of confidentiality at least as protective as this agreement. The receiving party remains responsible for any breach by anyone it shares the information with. +""" + +[clauses.term] +heading = "Term and Survival" +body = """ +This agreement takes effect on {{effective_date}} and continues {{term_text}}. The confidentiality obligations in this agreement survive for {{confidentiality_years}} years after that, after which the information may be freely used unless it qualifies for trade-secret protection under applicable law, in which case the obligations survive for as long as the information remains a trade secret. +""" + +[clauses.return_or_destroy] +heading = "Return or Destruction" +body = """ +On written request from the disclosing party, or when this agreement ends, the receiving party will promptly return or destroy all Confidential Information in its possession (including copies), other than (i) one archive copy retained by its legal or compliance function and (ii) routine electronic backups that cannot be reasonably deleted. Anything retained remains subject to this agreement. +""" + +[clauses.no_license] +heading = "No Licence, No Warranty" +body = """ +Nothing in this agreement gives either party any rights — by licence, ownership, or otherwise — in the other party’s Confidential Information or intellectual property. Confidential Information is provided “as is”. Neither party makes any warranty as to its accuracy or completeness. +""" + +[clauses.no_obligation] +heading = "No Commitment" +body = """ +Neither party is obliged by this agreement to enter into any further agreement, to share any specific information, or to pursue the Purpose. Either party may end its participation in discussions at any time. +""" + +[clauses.remedies] +heading = "Remedies" +body = """ +Both parties acknowledge that a breach of this agreement may cause irreparable harm for which money damages would not be adequate. The non-breaching party may seek injunctive or other equitable relief in any court of competent jurisdiction — notwithstanding the exclusive jurisdiction clause in this agreement — in addition to any other remedies available at law. +""" + +[clauses.governing_law] +heading = "Governing Law" +body = """ +This agreement is governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}} for any dispute arising out of or in connection with it. +""" + +[clauses.entire_agreement] +heading = "Entire Agreement" +body = """ +This agreement is the whole agreement between the parties on its subject and replaces prior discussions. Neither party has relied on any statement not set out in it. +""" + +[clauses.variation] +heading = "Variation and Waiver" +body = """ +Changes to this agreement must be in writing and signed by both parties. A failure or delay in exercising a right is not a waiver of it. +""" + +[clauses.severance] +heading = "Severance" +body = """ +If any part of this agreement is found invalid or unenforceable, that part is deemed deleted and the rest stays in force. +""" + +[clauses.assignment] +heading = "Assignment" +body = """ +Neither party may assign its rights or obligations under this agreement without the other’s written consent, except to an affiliate or successor in connection with a sale of its business. +""" + +[clauses.notices] +heading = "Notices" +body = """ +Notices under this agreement must be in writing and sent to the email or address on the signature page, or as later notified in writing. +""" + +[clauses.counterparts] +heading = "Counterparts and Electronic Signatures" +body = """ +This agreement may be signed in any number of counterparts, each of which is an original and which together form one agreement. Signatures exchanged electronically — including scanned and e-signed copies — are valid and binding. +""" + +[clauses.third_party_rights] +heading = "Third-Party Rights" +body = """ +A person who is not a party to this agreement has no right to enforce any of its terms, whether under the Contracts (Rights of Third Parties) Act 1999 or otherwise. +""" + +[clauses.no_partnership] +heading = "No Partnership" +body = """ +Nothing in this agreement creates a partnership, joint venture, agency, or employment relationship between the parties. +""" + +# Optional clauses — not in the default set; add with --include. + +[clauses.non_circumvention] +heading = "Non-Circumvention" +body = """ +Neither party will use the other’s Confidential Information, or any introduction made under this agreement, to circumvent or bypass the other party in relation to any contact, counterparty, or opportunity first made known to it by the other party — including by dealing with that contact directly so as to exclude the other party from a transaction or from remuneration separately agreed with it. This obligation applies during the term of this agreement and for {{non_circumvention_months}} months after it ends; if no period is stated, it applies for as long as the confidentiality obligations survive. +""" + +[clauses.non_solicit] +heading = "Non-Solicitation" +body = """ +While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact in connection with the Purpose. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. +""" diff --git a/clauses/archive/service/standard-1.0.toml b/clauses/archive/service/standard-1.0.toml new file mode 100644 index 0000000..b668854 --- /dev/null +++ b/clauses/archive/service/standard-1.0.toml @@ -0,0 +1,81 @@ +[pack] +slug = "standard" +name = "Standard Service Agreement (plain English, ongoing)" +version = "1.0" +kind = "service" +default_clauses = [ + "background", + "services", + "fees", + "term_renewal", + "support_changes", + "data_handling", + "warranties", + "liability", + "termination", + "general", +] + +[clauses.background] +heading = "Background" +body = """ +{{our_legal_name}} (the “Provider”) supplies the services described below. {{their_legal_name}} (the “Customer”) wishes to receive them on the terms set out in this agreement. +""" + +[clauses.services] +heading = "Services" +body = """ +The Provider will supply: {{purpose}}. + +{{deliverables_block}} +""" + +[clauses.fees] +heading = "Fees" +body = """ +{{fee_text}}. + +Fees are payable within 14 days of invoice. Late amounts may accrue interest at 1% per month. Taxes are added where the Provider is required to charge them. +""" + +[clauses.term_renewal] +heading = "Term and Renewal" +body = """ +This agreement starts on {{effective_date}} and continues {{term_text}}. After the initial term, it renews for successive 12-month periods unless either party gives the other at least 30 days’ written notice before the end of the then-current term that it does not wish to renew. +""" + +[clauses.support_changes] +heading = "Support and Changes" +body = """ +The Provider will use commercially reasonable efforts to keep the services available and to respond to support requests during normal business hours. The Provider may change non-material features from time to time. Material changes that reduce the services available to the Customer will be communicated in advance, and the Customer may terminate this agreement (without fee) within 30 days of such a change. +""" + +[clauses.data_handling] +heading = "Data Handling" +body = """ +The Provider will handle Customer data with reasonable care, will not use it for any purpose other than providing the services and complying with law, and will apply appropriate technical and organisational security measures. The Customer remains the owner of its data. On termination, the Provider will return or delete Customer data within a reasonable period, except as required by law or routine backups. +""" + +[clauses.warranties] +heading = "Warranties" +body = """ +The Provider warrants that it will perform the services with reasonable skill and care. The Provider does not warrant that the services will be uninterrupted or error-free. +""" + +[clauses.liability] +heading = "Limitation of Liability" +body = """ +Neither party is liable to the other for indirect, consequential, or loss-of-profits damages. Each party’s total liability under this agreement is capped at the fees paid by the Customer in the twelve months before the event giving rise to the claim. Nothing in this clause limits liability that cannot be limited under applicable law. +""" + +[clauses.termination] +heading = "Termination" +body = """ +Either party may terminate this agreement immediately for material breach not cured within 15 days of written notice, or if the other party becomes insolvent. The Customer may terminate for convenience by giving the Provider at least {{termination_notice_days}} days’ written notice. The Customer remains liable for fees up to the termination date. +""" + +[clauses.general] +heading = "General" +body = """ +This agreement is the whole agreement on its subject. Changes must be in writing signed by both parties. If any part is unenforceable, the rest stays in force. Neither party may assign without the other’s consent, except to an affiliate or successor in connection with a sale of its business. Notices may be given by email to the address on the signature page. This agreement is governed by the laws of {{governing_law}} and the parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. +""" diff --git a/clauses/archive/sow/standard-1.0.toml b/clauses/archive/sow/standard-1.0.toml new file mode 100644 index 0000000..845bc09 --- /dev/null +++ b/clauses/archive/sow/standard-1.0.toml @@ -0,0 +1,67 @@ +[pack] +slug = "standard" +name = "Standard Statement of Work (plain English)" +version = "1.0" +kind = "sow" +default_clauses = [ + "reference", + "description", + "deliverables", + "fees", + "timing", + "acceptance", + "assumptions", + "signature_note", +] + +[clauses.reference] +heading = "Reference to Master Agreement" +body = """ +This Statement of Work (“SOW”) is entered into under the Master Services Agreement between {{our_legal_name}} (the “Provider”) and {{their_legal_name}} (the “Client”). The MSA’s terms apply to this SOW; where this SOW conflicts with the MSA, this SOW controls for this project. +""" + +[clauses.description] +heading = "Project Description" +body = """ +{{purpose}} +""" + +[clauses.deliverables] +heading = "Deliverables" +body = """ +The Provider will deliver the following: + +{{deliverables_block}} +""" + +[clauses.fees] +heading = "Fees" +body = """ +{{fee_text}}. + +Invoices are payable within 14 days of receipt. Reasonable out-of-pocket expenses are reimbursable when pre-approved in writing. +""" + +[clauses.timing] +heading = "Timing" +body = """ +Work begins on {{effective_date}} and runs {{term_text}}. Specific milestones, if any, are listed under Deliverables. +""" + +[clauses.acceptance] +heading = "Acceptance" +body = """ +Each deliverable is deemed accepted when (i) the Client confirms acceptance in writing (email is fine) or (ii) seven days after delivery, whichever is earlier, unless the Client reasonably rejects it in writing within that period describing what is materially missing or non-conforming. The Provider will then have a reasonable opportunity to correct it. +""" + +[clauses.assumptions] +heading = "Assumptions" +body = """ +This SOW is based on the Provider receiving timely access, feedback, and inputs from the Client. Material delays or changes to the inputs may impact timing and fees; any such impact will be agreed in writing before extra work is performed. +""" + +[clauses.signature_note] +heading = "Authority" +body = """ +By signing below, each signatory confirms they are authorised to sign on behalf of their organisation and to bind that organisation to this SOW. +""" diff --git a/clauses/consulting/design.toml b/clauses/consulting/design.toml new file mode 100644 index 0000000..7698f8e --- /dev/null +++ b/clauses/consulting/design.toml @@ -0,0 +1,128 @@ +[pack] +slug = "design" +name = "Design services agreement" +version = "2.0" +kind = "consulting" +default_clauses = ["background", "services", "deliverables", "fees_and_expenses", "timing", "relationship", "ip_ownership", "confidentiality", "warranties", "liability", "termination", "general", "data_protection", "acceptance", "creative_scope", "third_party_assets", "publicity"] + +[clauses.background] +heading = "Background" +body = """ +{{our_legal_name}} (the “Consultant”) provides advisory and delivery services. {{their_legal_name}} (the “Client”) wishes to engage the Consultant in connection with {{purpose}}. This agreement sets out the terms. +""" + +[clauses.services] +heading = "Services" +body = """ +The Consultant shall provide the Services expressly described in the Deliverables and any signed change order, exercising reasonable skill and care. No ancillary work is included unless agreed in writing. The Consultant shall keep the Client informed of material progress, dependencies and delays. +""" + +[clauses.deliverables] +heading = "Deliverables" +body = """ +The Consultant will deliver the following: + +{{deliverables_block}} + +If the Client wants something materially outside this list, the parties will agree the change — and any impact on fees or timing — in writing (email is fine) before the additional work begins. +""" + +[clauses.fees_and_expenses] +heading = "Fees and Expenses" +body = """ +The Client will pay the Consultant {{fee_text}}. + +Invoices are payable within 14 days of receipt. Statutory interest and recovery costs may be claimed on overdue amounts only to the extent available under applicable law; no additional contractual default interest is imposed. Reasonable out-of-pocket expenses (travel, materials, third-party software) are reimbursable when pre-approved in writing. +""" + +[clauses.timing] +heading = "Timing" +body = """ +The engagement begins on {{effective_date}} and continues {{term_text}}. The Consultant shall promptly notify the Client of a delay and take reasonable steps to mitigate it. Dates extend only to the extent actually affected by a Client dependency; additional fees require prior written agreement. +""" + +[clauses.relationship] +heading = "Independent Contractor" +body = """ +The Consultant is an independent contractor, not an employee, agent, partner, or joint-venture partner of the Client. Nothing in this agreement creates such a relationship. The Consultant is responsible for its own taxes, social contributions, and insurance, and may engage subcontractors provided the Consultant remains responsible for their work. +""" + +[clauses.ip_ownership] +heading = "Intellectual property" +body = """ +{{ip_assignment_text}} +""" + +[clauses.confidentiality] +heading = "Confidentiality" +body = """ +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +""" + +[clauses.warranties] +heading = "Warranties" +body = """ +Each party warrants its authority to enter this agreement. The Consultant warrants reasonable skill and care, authority to grant the agreed IP rights, and that it shall not knowingly include infringing material. The Client warrants it has the rights needed for materials and instructions it supplies. No specific commercial result is promised. +""" + +[clauses.liability] +heading = "Liability" +body = """ +Neither party is liable for indirect or consequential loss. Subject to the exclusions below, each party's total aggregate liability arising out of or in connection with this agreement, whether in contract, tort (including negligence), misrepresentation or otherwise, shall not exceed the total fees paid or payable under this agreement. This cap does not reduce the obligation to pay properly due fees. + +Nothing excludes or limits liability for death or personal injury caused by negligence, fraud or fraudulent misrepresentation, or any liability that applicable law does not permit to be excluded or limited. Any restriction applies only to the extent lawful and, where required, reasonable. A separate signed SOW may expressly agree a different cap for that SOW; it must identify this clause and the replacement cap. +""" + +[clauses.termination] +heading = "Termination" +body = """ +Either party may terminate this agreement for convenience by giving the other at least {{termination_notice_days}} days’ written notice. Either party may terminate immediately for material breach that is not cured within 15 days of written notice describing it, or, to the extent permitted by applicable insolvency law, if the other party becomes insolvent. On termination, the Client will pay the Consultant for Services performed up to the termination date and for non-cancellable expenses. Clauses that by their nature should survive (confidentiality, IP, liability, governing law) survive termination. +""" + +[clauses.general] +heading = "General provisions" +body = """ +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. + +This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. +""" + +[clauses.non_solicit] +heading = "Non-Solicitation" +body = """ +While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact under this engagement. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. +""" + +[clauses.data_protection] +heading = "Data protection and security" +body = """ +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. + +Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. +""" + +[clauses.acceptance] +heading = "Review and acceptance" +body = """ +Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. Acceptance does not waive rights relating to latent defects or express warranties. Changes to agreed criteria or additional work require a written change order setting out fees and timing. +""" + +[clauses.creative_scope] +heading = "Creative scope and revisions" +body = """ +The agreed scope includes {{revision_rounds}} consolidated revision rounds for each deliverable. Each round consists of one coordinated written set of feedback from the Client's nominated representative. A new direction after approval, additional concepts or extra revision rounds require an agreed change order. The final deliverables and included editable source files must be listed expressly. Preliminary concepts, unused work and working files remain the Consultant's property unless expressly included in the paid final deliverables. +""" + +[clauses.third_party_assets] +heading = "Fonts, assets and production" +body = """ +Font software, stock images, illustrations and other licensed assets remain subject to third-party terms. The Consultant shall identify required licences and costs before commitment; the Client shall obtain any end-user licences allocated to it in the agreed scope. The Client is responsible for approving final copy, facts and production proofs. The Consultant remains responsible for conforming its work to the approved specification. +""" + +[clauses.publicity] +heading = "Portfolio and publicity" +body = """ +Neither party may publish the other's name, marks, confidential information or project work without prior written approval specifying the content and timing. No portfolio permission is implied by payment, acceptance or public launch. +""" diff --git a/clauses/consulting/standard.toml b/clauses/consulting/standard.toml index aa8c33a..bf8e774 100644 --- a/clauses/consulting/standard.toml +++ b/clauses/consulting/standard.toml @@ -1,22 +1,9 @@ [pack] slug = "standard" name = "Standard Consulting Agreement (plain English)" -version = "1.1" +version = "2.0" kind = "consulting" -default_clauses = [ - "background", - "services", - "deliverables", - "fees_and_expenses", - "timing", - "relationship", - "ip_ownership", - "confidentiality", - "warranties", - "liability", - "termination", - "general", -] +default_clauses = ["background", "services", "deliverables", "fees_and_expenses", "timing", "relationship", "ip_ownership", "confidentiality", "warranties", "liability", "termination", "general", "data_protection"] [clauses.background] heading = "Background" @@ -27,7 +14,7 @@ body = """ [clauses.services] heading = "Services" body = """ -The Consultant will provide the services described under “Deliverables” below, together with such ancillary work as the parties reasonably agree from time to time (the “Services”). The Consultant will perform the Services with reasonable skill and care, and will keep the Client informed of progress. +The Consultant shall provide the Services expressly described in the Deliverables and any signed change order, exercising reasonable skill and care. No ancillary work is included unless agreed in writing. The Consultant shall keep the Client informed of material progress, dependencies and delays. """ [clauses.deliverables] @@ -45,13 +32,13 @@ heading = "Fees and Expenses" body = """ The Client will pay the Consultant {{fee_text}}. -Invoices are payable within 14 days of receipt. Late amounts may accrue interest at 1% per month from the due date. Reasonable out-of-pocket expenses (travel, materials, third-party software) are reimbursable when pre-approved in writing. +Invoices are payable within 14 days of receipt. Statutory interest and recovery costs may be claimed on overdue amounts only to the extent available under applicable law; no additional contractual default interest is imposed. Reasonable out-of-pocket expenses (travel, materials, third-party software) are reimbursable when pre-approved in writing. """ [clauses.timing] heading = "Timing" body = """ -The engagement begins on {{effective_date}} and runs {{term_text}}. The parties will work in good faith to meet any milestones agreed in writing. Delays caused primarily by the Client (late feedback, missing inputs, scope changes) extend the relevant dates by an equivalent amount and do not change the fee. +The engagement begins on {{effective_date}} and continues {{term_text}}. The Consultant shall promptly notify the Client of a delay and take reasonable steps to mitigate it. Dates extend only to the extent actually affected by a Client dependency; additional fees require prior written agreement. """ [clauses.relationship] @@ -61,47 +48,57 @@ The Consultant is an independent contractor, not an employee, agent, partner, or """ [clauses.ip_ownership] -heading = "Intellectual Property" +heading = "Intellectual property" body = """ {{ip_assignment_text}} - -The Consultant keeps ownership of any tools, libraries, methods, frameworks, or general know-how it had before, or developed independently of, this engagement (the Consultant’s “Background IP”). To the extent any Background IP is incorporated into a Deliverable, the Consultant grants the Client a non-exclusive, perpetual, royalty-free licence to use it as part of that Deliverable. """ [clauses.confidentiality] heading = "Confidentiality" body = """ -Each party will treat the other’s non-public information (including business plans, customer data, financials, and technical materials) with reasonable care, use it only for the engagement, and share it inside its organisation only on a need-to-know basis. This obligation survives for {{confidentiality_years}} years after the engagement ends. It does not apply to information that becomes public other than through that party’s fault, was already known on a non-confidential basis, or is independently developed. +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. """ [clauses.warranties] heading = "Warranties" body = """ -The Consultant warrants that it will perform the Services with reasonable skill and care, in line with industry standards, and that to the best of its knowledge the Deliverables will not knowingly infringe any third-party intellectual property. The Consultant does not warrant any specific business outcome. The Client warrants that it has the authority to enter into this agreement and to provide any materials it shares with the Consultant. +Each party warrants its authority to enter this agreement. The Consultant warrants reasonable skill and care, authority to grant the agreed IP rights, and that it shall not knowingly include infringing material. The Client warrants it has the rights needed for materials and instructions it supplies. No specific commercial result is promised. """ [clauses.liability] -heading = "Limitation of Liability" +heading = "Liability" body = """ -Neither party is liable to the other for indirect, consequential, or loss-of-profits damages. Each party’s total liability under this agreement is capped at the fees paid or payable to the Consultant under this agreement in the twelve months before the event giving rise to the claim. Nothing in this clause limits liability that cannot be limited under applicable law, including liability for fraud or willful misconduct. +Neither party is liable for indirect or consequential loss. Subject to the exclusions below, each party's total aggregate liability arising out of or in connection with this agreement, whether in contract, tort (including negligence), misrepresentation or otherwise, shall not exceed the total fees paid or payable under this agreement. This cap does not reduce the obligation to pay properly due fees. + +Nothing excludes or limits liability for death or personal injury caused by negligence, fraud or fraudulent misrepresentation, or any liability that applicable law does not permit to be excluded or limited. Any restriction applies only to the extent lawful and, where required, reasonable. A separate signed SOW may expressly agree a different cap for that SOW; it must identify this clause and the replacement cap. """ [clauses.termination] heading = "Termination" body = """ -Either party may terminate this agreement for convenience by giving the other at least {{termination_notice_days}} days’ written notice. Either party may terminate immediately for material breach that is not cured within 15 days of written notice describing it, or if the other party becomes insolvent. On termination, the Client will pay the Consultant for Services performed up to the termination date and for non-cancellable expenses. Clauses that by their nature should survive (confidentiality, IP, liability, governing law) survive termination. +Either party may terminate this agreement for convenience by giving the other at least {{termination_notice_days}} days’ written notice. Either party may terminate immediately for material breach that is not cured within 15 days of written notice describing it, or, to the extent permitted by applicable insolvency law, if the other party becomes insolvent. On termination, the Client will pay the Consultant for Services performed up to the termination date and for non-cancellable expenses. Clauses that by their nature should survive (confidentiality, IP, liability, governing law) survive termination. """ [clauses.general] -heading = "General" +heading = "General provisions" body = """ -This agreement is the whole agreement on its subject and replaces prior discussions. Changes must be in writing signed by both parties (email exchange is fine where the parties clearly confirm the change). If any part is unenforceable, the rest stays in force. Neither party may assign without the other’s consent, except to an affiliate or successor in connection with a sale of its business. Notices may be given by email to the address on the signature page. This agreement is governed by the laws of {{governing_law}} and the parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. -""" +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. -# Optional clauses — not in the default set; add with --include. +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. + +This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. +""" [clauses.non_solicit] heading = "Non-Solicitation" body = """ While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact under this engagement. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. """ + +[clauses.data_protection] +heading = "Data protection and security" +body = """ +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. + +Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. +""" diff --git a/clauses/consulting/technology.toml b/clauses/consulting/technology.toml new file mode 100644 index 0000000..5686423 --- /dev/null +++ b/clauses/consulting/technology.toml @@ -0,0 +1,122 @@ +[pack] +slug = "technology" +name = "Technology delivery agreement" +version = "2.0" +kind = "consulting" +default_clauses = ["background", "services", "deliverables", "fees_and_expenses", "timing", "relationship", "ip_ownership", "confidentiality", "warranties", "liability", "termination", "general", "data_protection", "acceptance", "delivery_security", "dependencies"] + +[clauses.background] +heading = "Background" +body = """ +{{our_legal_name}} (the “Consultant”) provides advisory and delivery services. {{their_legal_name}} (the “Client”) wishes to engage the Consultant in connection with {{purpose}}. This agreement sets out the terms. +""" + +[clauses.services] +heading = "Services" +body = """ +The Consultant shall provide the Services expressly described in the Deliverables and any signed change order, exercising reasonable skill and care. No ancillary work is included unless agreed in writing. The Consultant shall keep the Client informed of material progress, dependencies and delays. +""" + +[clauses.deliverables] +heading = "Deliverables" +body = """ +The Consultant will deliver the following: + +{{deliverables_block}} + +If the Client wants something materially outside this list, the parties will agree the change — and any impact on fees or timing — in writing (email is fine) before the additional work begins. +""" + +[clauses.fees_and_expenses] +heading = "Fees and Expenses" +body = """ +The Client will pay the Consultant {{fee_text}}. + +Invoices are payable within 14 days of receipt. Statutory interest and recovery costs may be claimed on overdue amounts only to the extent available under applicable law; no additional contractual default interest is imposed. Reasonable out-of-pocket expenses (travel, materials, third-party software) are reimbursable when pre-approved in writing. +""" + +[clauses.timing] +heading = "Timing" +body = """ +The engagement begins on {{effective_date}} and continues {{term_text}}. The Consultant shall promptly notify the Client of a delay and take reasonable steps to mitigate it. Dates extend only to the extent actually affected by a Client dependency; additional fees require prior written agreement. +""" + +[clauses.relationship] +heading = "Independent Contractor" +body = """ +The Consultant is an independent contractor, not an employee, agent, partner, or joint-venture partner of the Client. Nothing in this agreement creates such a relationship. The Consultant is responsible for its own taxes, social contributions, and insurance, and may engage subcontractors provided the Consultant remains responsible for their work. +""" + +[clauses.ip_ownership] +heading = "Intellectual property" +body = """ +{{ip_assignment_text}} +""" + +[clauses.confidentiality] +heading = "Confidentiality" +body = """ +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +""" + +[clauses.warranties] +heading = "Warranties" +body = """ +Each party warrants its authority to enter this agreement. The Consultant warrants reasonable skill and care, authority to grant the agreed IP rights, and that it shall not knowingly include infringing material. The Client warrants it has the rights needed for materials and instructions it supplies. No specific commercial result is promised. +""" + +[clauses.liability] +heading = "Liability" +body = """ +Neither party is liable for indirect or consequential loss. Subject to the exclusions below, each party's total aggregate liability arising out of or in connection with this agreement, whether in contract, tort (including negligence), misrepresentation or otherwise, shall not exceed the total fees paid or payable under this agreement. This cap does not reduce the obligation to pay properly due fees. + +Nothing excludes or limits liability for death or personal injury caused by negligence, fraud or fraudulent misrepresentation, or any liability that applicable law does not permit to be excluded or limited. Any restriction applies only to the extent lawful and, where required, reasonable. A separate signed SOW may expressly agree a different cap for that SOW; it must identify this clause and the replacement cap. +""" + +[clauses.termination] +heading = "Termination" +body = """ +Either party may terminate this agreement for convenience by giving the other at least {{termination_notice_days}} days’ written notice. Either party may terminate immediately for material breach that is not cured within 15 days of written notice describing it, or, to the extent permitted by applicable insolvency law, if the other party becomes insolvent. On termination, the Client will pay the Consultant for Services performed up to the termination date and for non-cancellable expenses. Clauses that by their nature should survive (confidentiality, IP, liability, governing law) survive termination. +""" + +[clauses.general] +heading = "General provisions" +body = """ +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. + +This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. +""" + +[clauses.non_solicit] +heading = "Non-Solicitation" +body = """ +While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact under this engagement. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. +""" + +[clauses.data_protection] +heading = "Data protection and security" +body = """ +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. + +Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. +""" + +[clauses.acceptance] +heading = "Review and acceptance" +body = """ +Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. Acceptance does not waive rights relating to latent defects or express warranties. Changes to agreed criteria or additional work require a written change order setting out fees and timing. +""" + +[clauses.delivery_security] +heading = "Software delivery and security" +body = """ +The agreed deliverables shall identify supported platforms, source repositories, build and deployment instructions, test criteria and handover materials. The Consultant shall use reasonable secure development practices, restrict access to project secrets, and remediate material security defects in its work discovered before acceptance. Deployment to production requires the Client's written authorisation. The Client shall provide lawful access, maintain backups and approve the production environment. Ongoing hosting, maintenance, uptime commitments and incident response are included only if expressly stated in a signed schedule. +""" + +[clauses.dependencies] +heading = "Third-party software and AI-assisted work" +body = """ +The Consultant shall identify material third-party and open-source components, applicable licence terms and recurring costs before delivery. Components imposing source-disclosure or reciprocal licensing duties on Client proprietary code require prior written approval. Third-party rights remain governed by their licences. The Consultant remains responsible for reviewing and testing AI-assisted work against the agreed specification and shall disclose material AI-generated content and any known limitations on the rights it can grant. Use of AI does not reduce its contractual obligations. +""" diff --git a/clauses/loan/standard.toml b/clauses/loan/standard.toml index 8492a00..5466ed2 100644 --- a/clauses/loan/standard.toml +++ b/clauses/loan/standard.toml @@ -1,25 +1,9 @@ [pack] slug = "standard" name = "Standard Loan Agreement (plain English)" -version = "1.0" +version = "2.0" kind = "loan" -default_clauses = [ - "the_loan", - "advance", - "interest", - "repayment", - "prepayment", - "default", - "set_off", - "costs", - "assignment", - "notices", - "no_partnership", - "third_party_rights", - "entire_agreement", - "counterparts", - "governing_law", -] +default_clauses = ["the_loan", "advance", "interest", "repayment", "prepayment", "default", "set_off", "costs", "assignment", "notices", "no_partnership", "third_party_rights", "entire_agreement", "counterparts", "governing_law", "regulatory_scope"] [clauses.the_loan] heading = "The Loan" @@ -84,7 +68,7 @@ The Borrower may not assign or transfer any of its rights or obligations under t [clauses.notices] heading = "Notices" body = """ -Notices under this agreement must be in writing and sent to the address or email of the relevant party on the signature page, or as later notified in writing. +Notices must be in writing and sent to the contact email or postal address in the Parties section, or a replacement notified in writing. An email takes effect when receipt is acknowledged by the recipient, excluding automated responses. If no acknowledgement is received, the sender must use delivery with evidence of receipt. This clause does not govern service of court proceedings. """ [clauses.no_partnership] @@ -96,19 +80,19 @@ Nothing in this agreement creates a partnership, joint venture, agency, or emplo [clauses.third_party_rights] heading = "Third-Party Rights" body = """ -A person who is not a party to this agreement has no right to enforce any of its terms, whether under the Contracts (Rights of Third Parties) Act 1999 or otherwise. +A person who is not a party to this agreement has no right to enforce any of its terms, except where mandatory law provides otherwise. """ [clauses.entire_agreement] heading = "Entire Agreement" body = """ -This agreement is the entire agreement between the parties on the Loan and replaces any prior discussions or understandings on the same subject. Changes must be in writing and signed by both parties. +This agreement is the entire agreement between the parties on the Loan and replaces prior discussions on the same subject. Changes must be in writing and signed by both parties. Nothing excludes or limits liability for fraud, fraudulent misrepresentation or any liability that cannot lawfully be excluded. """ [clauses.counterparts] heading = "Counterparts and Electronic Signatures" body = """ -This agreement may be signed in any number of counterparts, each of which is an original and which together form one agreement. Signatures exchanged electronically — including scanned and e-signed copies — are valid and binding. +This agreement may be signed in any number of counterparts, each of which is an original and which together form one agreement. Signatures exchanged electronically — including scanned and e-signed copies — may be used where permitted by applicable law and all required execution formalities are met. """ [clauses.governing_law] @@ -116,3 +100,9 @@ heading = "Governing Law and Jurisdiction" body = """ This agreement, and any dispute arising out of or in connection with it, are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. """ + +[clauses.regulatory_scope] +heading = "Regulatory scope" +body = """ +The parties shall establish before execution and advance whether this loan is subject to consumer credit, financial services, moneylending or other mandatory regulation, and obtain any required permissions and prescribed documentation. This document does not disapply those requirements and must not be used as a substitute for prescribed regulated-credit documentation. +""" diff --git a/clauses/msa/standard.toml b/clauses/msa/standard.toml index 6c7cc1c..9b9a519 100644 --- a/clauses/msa/standard.toml +++ b/clauses/msa/standard.toml @@ -1,20 +1,9 @@ [pack] slug = "standard" name = "Standard Master Services Agreement (plain English)" -version = "1.0" +version = "2.0" kind = "msa" -default_clauses = [ - "background", - "services_via_sow", - "fees_general", - "term", - "confidentiality", - "ip_ownership", - "warranties", - "liability", - "termination", - "general", -] +default_clauses = ["background", "services_via_sow", "fees_general", "term", "confidentiality", "ip_ownership", "warranties", "liability", "termination", "general", "data_protection"] [clauses.background] heading = "Background" @@ -43,15 +32,13 @@ This agreement takes effect on {{effective_date}} and continues {{term_text}}. E [clauses.confidentiality] heading = "Confidentiality" body = """ -Each party will treat the other’s non-public information (including business plans, customer data, financials, and technical materials) with reasonable care, use it only for the engagement, and share it inside its organisation only on a need-to-know basis. This obligation survives {{confidentiality_years}} years after the engagement ends, and does not apply to information that becomes public other than through that party’s fault, was already known on a non-confidential basis, or is independently developed. +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. """ [clauses.ip_ownership] -heading = "Intellectual Property" +heading = "Intellectual property" body = """ {{ip_assignment_text}} - -The Provider keeps ownership of any tools, libraries, methods, frameworks, or general know-how it had before, or developed independently of, the engagement (the Provider’s “Background IP”). To the extent any Background IP is incorporated into a Deliverable, the Provider grants the Client a non-exclusive, perpetual, royalty-free licence to use it as part of that Deliverable. """ [clauses.warranties] @@ -61,19 +48,33 @@ The Provider warrants that it will perform the services with reasonable skill an """ [clauses.liability] -heading = "Limitation of Liability" +heading = "Liability" body = """ -Neither party is liable to the other for indirect, consequential, or loss-of-profits damages. Each party’s total liability under any SOW is capped at the fees paid or payable under that SOW in the twelve months before the event giving rise to the claim. Nothing in this clause limits liability that cannot be limited under applicable law, including liability for fraud or willful misconduct. +Neither party is liable for indirect or consequential loss. Subject to the exclusions below, each party's total aggregate liability arising out of or in connection with this agreement, whether in contract, tort (including negligence), misrepresentation or otherwise, shall not exceed the fees paid or payable under the affected SOW or SOWs during their first twelve months, or during the twelve months preceding the first event giving rise to the claim, whichever amount is higher. This cap does not reduce the obligation to pay properly due fees. + +Nothing excludes or limits liability for death or personal injury caused by negligence, fraud or fraudulent misrepresentation, or any liability that applicable law does not permit to be excluded or limited. Any restriction applies only to the extent lawful and, where required, reasonable. A separate signed SOW may expressly agree a different cap for that SOW; it must identify this clause and the replacement cap. """ [clauses.termination] heading = "Termination of an SOW" body = """ -Either party may terminate an SOW immediately for material breach that is not cured within 15 days of written notice, or if the other party becomes insolvent. Either party may terminate an SOW for convenience by giving the other at least {{termination_notice_days}} days’ written notice, unless the SOW says otherwise. On termination of an SOW, the Client will pay the Provider for services performed up to the termination date and for non-cancellable expenses. +Either party may terminate an SOW immediately for material breach that is not cured within 15 days of written notice, or, to the extent permitted by applicable insolvency law, if the other party becomes insolvent. Either party may terminate an SOW for convenience by giving the other at least {{termination_notice_days}} days’ written notice, unless the SOW says otherwise. On termination of an SOW, the Client will pay the Provider for services performed up to the termination date and for non-cancellable expenses. """ [clauses.general] -heading = "General" +heading = "General provisions" +body = """ +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. + +This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. +""" + +[clauses.data_protection] +heading = "Data protection and security" body = """ -This agreement (together with any SOWs) is the whole agreement on its subject and replaces prior discussions. Changes must be in writing signed by both parties. If any part is unenforceable, the rest stays in force. Neither party may assign without the other’s consent, except to an affiliate or successor in connection with a sale of its business. Notices may be given by email to the address on the signature page. This agreement is governed by the laws of {{governing_law}} and the parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. + +Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. """ diff --git a/clauses/msa/startup.toml b/clauses/msa/startup.toml new file mode 100644 index 0000000..d006260 --- /dev/null +++ b/clauses/msa/startup.toml @@ -0,0 +1,92 @@ +[pack] +slug = "startup" +name = "Startup services framework" +version = "2.0" +kind = "msa" +default_clauses = ["background", "services_via_sow", "fees_general", "term", "confidentiality", "ip_ownership", "warranties", "liability", "termination", "general", "data_protection", "commercial_scope", "handover"] + +[clauses.background] +heading = "Background" +body = """ +{{our_legal_name}} (the “Provider”) provides professional services to clients. {{their_legal_name}} (the “Client”) wishes to engage the Provider on multiple projects over time without renegotiating common terms each time. This agreement sets out those common terms; specific projects are described in separate Statements of Work (each, an “SOW”). +""" + +[clauses.services_via_sow] +heading = "Services Under SOWs" +body = """ +From time to time, the parties may sign one or more SOWs that reference this agreement. Each SOW describes the services, deliverables, fees, timing, and any project-specific terms. Each signed SOW is incorporated into this agreement. If there is a conflict between this agreement and an SOW, the SOW controls for that project unless the SOW says otherwise. +""" + +[clauses.fees_general] +heading = "Fees and Expenses (General)" +body = """ +Fees, payment schedules, and expense rules are set in each SOW. Unless an SOW says otherwise, invoices are payable within 14 days of receipt and reasonable pre-approved out-of-pocket expenses are reimbursable. Taxes are charged in addition to fees where the Provider is required to charge them. +""" + +[clauses.term] +heading = "Term" +body = """ +This agreement takes effect on {{effective_date}} and continues {{term_text}}. Either party may terminate this agreement when no SOWs are in effect by giving the other 30 days’ written notice. Termination of this agreement does not by itself terminate any in-flight SOW; in-flight SOWs continue to be governed by this agreement until they end. +""" + +[clauses.confidentiality] +heading = "Confidentiality" +body = """ +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +""" + +[clauses.ip_ownership] +heading = "Intellectual property" +body = """ +{{ip_assignment_text}} +""" + +[clauses.warranties] +heading = "Warranties" +body = """ +The Provider warrants that it will perform the services with reasonable skill and care, in line with industry standards, and that to the best of its knowledge the deliverables will not knowingly infringe any third-party intellectual property. The Provider does not warrant any specific business outcome. +""" + +[clauses.liability] +heading = "Liability" +body = """ +Neither party is liable for indirect or consequential loss. Subject to the exclusions below, each party's total aggregate liability arising out of or in connection with this agreement, whether in contract, tort (including negligence), misrepresentation or otherwise, shall not exceed the fees paid or payable under the affected SOW or SOWs during their first twelve months, or during the twelve months preceding the first event giving rise to the claim, whichever amount is higher. This cap does not reduce the obligation to pay properly due fees. + +Nothing excludes or limits liability for death or personal injury caused by negligence, fraud or fraudulent misrepresentation, or any liability that applicable law does not permit to be excluded or limited. Any restriction applies only to the extent lawful and, where required, reasonable. A separate signed SOW may expressly agree a different cap for that SOW; it must identify this clause and the replacement cap. +""" + +[clauses.termination] +heading = "Termination of an SOW" +body = """ +Either party may terminate an SOW immediately for material breach that is not cured within 15 days of written notice, or, to the extent permitted by applicable insolvency law, if the other party becomes insolvent. Either party may terminate an SOW for convenience by giving the other at least {{termination_notice_days}} days’ written notice, unless the SOW says otherwise. On termination of an SOW, the Client will pay the Provider for services performed up to the termination date and for non-cancellable expenses. +""" + +[clauses.general] +heading = "General provisions" +body = """ +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. + +This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. +""" + +[clauses.data_protection] +heading = "Data protection and security" +body = """ +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. + +Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. +""" + +[clauses.commercial_scope] +heading = "Services, funding and equity" +body = """ +Each SOW must state its scope, delivery dependencies, payment milestones and any spending cap. Neither a funding discussion nor this agreement promises investment, fundraising success or a commercial outcome. No equity, options, revenue share or success fee is granted under this agreement; any such arrangement requires a separate signed instrument and any necessary corporate and regulatory approvals. +""" + +[clauses.handover] +heading = "Continuity and handover" +body = """ +Each SOW shall identify the Client-owned accounts, repositories, domains and deliverables needed to continue operating after the engagement. On expiry or termination, the Provider shall return Client-controlled credentials and Client materials promptly, provide paid-for deliverables and reasonably cooperate in transition. Additional transition work and fees must be agreed in writing. Neither party may use the other's information for model training or publicity without express written permission. +""" diff --git a/clauses/ncnda/standard.toml b/clauses/ncnda/standard.toml index 4f9e2eb..52abb1d 100644 --- a/clauses/ncnda/standard.toml +++ b/clauses/ncnda/standard.toml @@ -1,29 +1,9 @@ [pack] slug = "standard" name = "Standard Non-Circumvention & Non-Disclosure Agreement (plain English)" -version = "1.0" +version = "2.0" kind = "ncnda" -default_clauses = [ - "purpose", - "definitions", - "confidentiality", - "permitted_disclosures", - "non_circumvention", - "introducer_role", - "prior_dealings", - "remedies", - "no_obligation", - "term", - "entire_agreement", - "variation", - "assignment", - "severance", - "third_party_rights", - "notices", - "no_partnership", - "counterparts", - "governing_law", -] +default_clauses = ["purpose", "definitions", "confidentiality", "permitted_disclosures", "non_circumvention", "introducer_role", "prior_dealings", "remedies", "no_obligation", "term", "entire_agreement", "variation", "assignment", "severance", "third_party_rights", "notices", "no_partnership", "counterparts", "governing_law"] [clauses.purpose] heading = "Purpose" @@ -34,13 +14,11 @@ body = """ [clauses.definitions] heading = "Definitions" body = """ -“Confidential Information” means any non-public information one party (the “Disclosing Party”) makes available to the other (the “Receiving Party”) in connection with the Purpose, in any form and whether or not marked confidential. It includes the existence and content of the parties’ discussions, the identity and contact details of any person or entity introduced under this agreement, the terms of any proposed transaction, and any analysis, copy, or derivative of any of the foregoing. +“Confidential Information” means non-public information disclosed for the Purpose that is identified as confidential or that a reasonable recipient would understand to be confidential from its nature and circumstances. It includes protected introductions and transaction discussions. It excludes information the recipient can demonstrate was lawfully known without restriction, becomes public without breach, is independently developed without use of the information, or is lawfully received from a third party without restriction. -“Introduction” means any introduction, direct or indirect, by one party to the other of a person, entity, asset, transaction, opportunity, or source of funds not already known to the receiving party on a non-confidential basis. +An “Introduction” is a written identification, for the Purpose, of a specific person or entity and opportunity by one party to the other. A “Protected Contact” is a person or entity identified in that Introduction that was not already lawfully known to the recipient in connection with that opportunity. The parties shall keep a written record of each protected introduction and its date. -“Protected Contact” means any person, entity, asset, transaction, opportunity, or source of funds first made known to a party by or through the other party under or in connection with this agreement. - -“Representatives” means, for each party, its directors, officers, employees, professional advisors, and agents. +“Representatives” means a party's directors, officers, employees, professional advisers and agents who need the information for the Purpose. """ [clauses.confidentiality] @@ -59,32 +37,29 @@ Neither party will make any public statement about the Purpose, this agreement, [clauses.permitted_disclosures] heading = "Permitted Disclosures" body = """ -A Receiving Party may disclose Confidential Information: +A Receiving Party may disclose Confidential Information to Representatives who need it for the Purpose and owe equivalent confidentiality duties; the Receiving Party remains responsible for their compliance. Disclosure required by law, regulation or a competent authority is permitted to the minimum extent required, with prior notice where lawful and practicable. -1. to those of its Representatives who need to know it for the Purpose, provided they are bound by confidentiality obligations at least as protective as this agreement — the Receiving Party remains responsible for their compliance; and -2. to the extent required by law, regulation, or a court or competent authority, provided that (where lawful and practicable) it gives the Disclosing Party prompt prior notice and discloses only what is required. +Nothing restricts reporting suspected crime, protected whistleblowing, cooperation with a regulator or confidential legal advice. No consent or prior notice is required for a disclosure protected by law where imposing that requirement would restrict the protected right. """ [clauses.non_circumvention] heading = "Non-Circumvention" body = """ -Neither party will, directly or indirectly, use any Confidential Information or any Introduction to circumvent, bypass, or exclude the party by or through whom a Protected Contact was introduced, in relation to any transaction arising from or materially connected with the Purpose and that Protected Contact. This includes soliciting, negotiating, arranging, brokering, concluding, or facilitating such a transaction without the introducing party’s prior written agreement, or otherwise than in a way that preserves the introducing party’s role and any remuneration separately agreed with it. Each party is responsible for any breach of this clause by its Representatives. +For {{non_circumvention_months}} months from the date of this agreement, neither party shall knowingly use a recorded Introduction or Confidential Information to bypass the introducing party in the specific opportunity described in that Introduction, with the purpose of avoiding a role or remuneration expressly agreed in writing with the introducing party. Each party remains responsible for its Representatives acting on its behalf. -This obligation lasts for {{non_circumvention_months}} months from the date of this agreement; if no period is stated, it lasts for the term of this agreement. +This clause does not prohibit general competition, independent opportunities, pre-existing relationships or dealings unrelated to the recorded opportunity. It applies only to the extent lawful and reasonably necessary to protect the introducing party's legitimate interest; it does not create a restraint broader than that interest. """ [clauses.introducer_role] heading = "Introductions and Remuneration" body = """ -Each party acknowledges that the Introductions the other makes have commercial value. A party that benefits from an Introduction will not conclude or pursue any transaction arising from it in a way that excludes the introducing party from its role as introducer or from any fee, commission, or other remuneration separately agreed with it. - -The parties record the agreed remuneration for Introductions as follows: {{commission_text}}. If none is recorded, remuneration (if any) remains as separately agreed in writing, and the absence of an agreed fee does not weaken the non-circumvention obligations in this agreement. +Any commission or other remuneration must be expressly agreed in a separate written fee schedule specifying the payer, amount or calculation, trigger, timing and applicable taxes. The current agreed position is: {{commission_text}}. This agreement does not create an implied fee, exclusivity, authority to bind another party, or authority to undertake regulated investment, financial or other intermediary activity. Any required authorisation must be obtained before that activity begins. """ [clauses.prior_dealings] heading = "Prior and Independent Contacts" body = """ -Nothing in this agreement prevents a party from dealing with a contact or opportunity that it can show, by written evidence, it already knew of — or was already dealing with independently — before the relevant Introduction, or that it lawfully obtains from a source other than the other party and without breach of this agreement. The burden of proving prior or independent knowledge rests on the party claiming it. +Nothing prevents a party from pursuing a contact or opportunity it can demonstrate it already knew or was independently developing before the Introduction, or subsequently obtains lawfully without using the other party's Confidential Information. Contemporaneous records may establish such circumstances. """ [clauses.remedies] @@ -104,13 +79,13 @@ Neither party is obliged by this agreement to proceed with the Purpose or any tr [clauses.term] heading = "Term and Survival" body = """ -This agreement takes effect on {{effective_date}} and continues {{term_text}}. The confidentiality obligations survive for {{confidentiality_years}} years after this agreement ends, and indefinitely for any information that qualifies as a trade secret under applicable law. The non-circumvention obligations survive in accordance with their terms. +This agreement takes effect on {{effective_date}} and continues {{term_text}}. Confidentiality duties survive for {{confidentiality_years}} years after this agreement ends, and for trade secrets while they remain protected under applicable law. The non-circumvention period is measured from the agreement date as stated in that clause and is not extended by termination. On request, Confidential Information shall be returned or deleted, except legally required records and inaccessible routine backups, which remain protected. """ [clauses.entire_agreement] heading = "Entire Agreement" body = """ -This agreement is the entire agreement between the parties on its subject and replaces any prior discussions or arrangements. Neither party has relied on any statement not set out in it. +This agreement is the entire agreement on its subject and supersedes earlier discussions. Nothing excludes or limits liability for fraud, fraudulent misrepresentation or any liability that cannot lawfully be excluded. """ [clauses.variation] @@ -122,7 +97,7 @@ Changes to this agreement must be in writing and signed by both parties. A failu [clauses.assignment] heading = "Assignment" body = """ -Neither party may assign or transfer any of its rights or obligations under this agreement without the other party’s prior written consent. +Assignment of rights requires the other party's prior written consent. Transfer of obligations requires a written novation agreed by all affected parties. """ [clauses.severance] @@ -132,15 +107,15 @@ If any provision of this agreement is or becomes invalid or unenforceable, it is """ [clauses.third_party_rights] -heading = "Third-Party Rights" +heading = "Third-party rights" body = """ -A person who is not a party to this agreement has no right to enforce any of its terms, whether under the Contracts (Rights of Third Parties) Act 1999 or otherwise. +A person who is not a party to this agreement has no right to enforce its terms, except where mandatory law provides otherwise. """ [clauses.notices] heading = "Notices" body = """ -Notices under this agreement must be in writing and sent to the address or email of the relevant party on the signature page, or as later notified in writing. +Notices must be in writing and sent to the contact email or postal address in the Parties section, or a replacement notified in writing. An email takes effect when receipt is acknowledged by the recipient, excluding automated responses. If no acknowledgement is received, the sender must use delivery with evidence of receipt. This clause does not govern service of court proceedings. """ [clauses.no_partnership] @@ -150,9 +125,9 @@ Nothing in this agreement creates a partnership, joint venture, agency, or emplo """ [clauses.counterparts] -heading = "Counterparts and Electronic Signatures" +heading = "Counterparts and signatures" body = """ -This agreement may be signed in any number of counterparts, each of which is an original and which together form one agreement. Signatures exchanged electronically — including scanned and e-signed copies — are valid and binding. +This agreement may be signed in counterparts and by electronic signature where applicable law permits and all required execution formalities are met. Each signatory confirms authority to bind the party for whom they sign. A typed name recorded in administrative software is not, by itself, proof of the signatory's consent. """ [clauses.governing_law] diff --git a/clauses/nda/standard.toml b/clauses/nda/standard.toml index 3456fab..1f56ba4 100644 --- a/clauses/nda/standard.toml +++ b/clauses/nda/standard.toml @@ -1,29 +1,9 @@ [pack] slug = "standard" name = "Standard NDA (plain English)" -version = "1.1" +version = "2.0" kind = "nda" -default_clauses = [ - "purpose", - "definitions", - "obligations", - "exclusions", - "permitted_disclosures", - "term", - "return_or_destroy", - "no_license", - "no_obligation", - "remedies", - "entire_agreement", - "variation", - "severance", - "assignment", - "notices", - "counterparts", - "third_party_rights", - "no_partnership", - "governing_law", -] +default_clauses = ["purpose", "definitions", "obligations", "exclusions", "permitted_disclosures", "term", "return_or_destroy", "no_license", "no_obligation", "remedies", "entire_agreement", "variation", "severance", "assignment", "notices", "counterparts", "third_party_rights", "no_partnership", "governing_law", "protected_disclosures"] [clauses.purpose] heading = "Purpose" @@ -32,9 +12,11 @@ body = """ """ [clauses.definitions] -heading = "Confidential Information" +heading = "Confidential information" body = """ -“Confidential Information” means any information — technical, commercial, financial, or otherwise — that one party makes available to the other in connection with the Purpose, whether shared in writing, verbally, or by any other means, and whether or not marked “confidential”. It includes information about products, research, methods, customers, finances, plans, and personnel. +{{nda_definition}} + +Confidential Information includes technical, commercial and financial information disclosed in connection with the Purpose in any form, including copies and analyses, where it is marked confidential or a reasonable person would understand it to be confidential given its nature and the circumstances. """ [clauses.obligations] @@ -68,9 +50,9 @@ The receiving party may share the Confidential Information with its directors, e """ [clauses.term] -heading = "Term and Survival" +heading = "Term and survival" body = """ -This agreement takes effect on {{effective_date}} and continues {{term_text}}. The confidentiality obligations in this agreement survive for {{confidentiality_years}} years after that, after which the information may be freely used unless it qualifies for trade-secret protection under applicable law, in which case the obligations survive for as long as the information remains a trade secret. +This agreement takes effect on {{effective_date}} and continues {{term_text}}. Either party may end the disclosure period by written notice. The receiving party's confidentiality and restricted-use obligations continue for {{confidentiality_years}} years after expiry or termination, and for trade secrets for as long as protected by applicable law. Expiry does not grant any intellectual-property licence or override data protection law. """ [clauses.return_or_destroy] @@ -104,9 +86,9 @@ This agreement is governed by the laws of {{governing_law}}. The parties submit """ [clauses.entire_agreement] -heading = "Entire Agreement" +heading = "Entire agreement" body = """ -This agreement is the whole agreement between the parties on its subject and replaces prior discussions. Neither party has relied on any statement not set out in it. +This agreement is the entire agreement on its subject and supersedes earlier discussions. Nothing excludes or limits liability for fraud or fraudulent misrepresentation or any liability that cannot lawfully be excluded. """ [clauses.variation] @@ -124,25 +106,25 @@ If any part of this agreement is found invalid or unenforceable, that part is de [clauses.assignment] heading = "Assignment" body = """ -Neither party may assign its rights or obligations under this agreement without the other’s written consent, except to an affiliate or successor in connection with a sale of its business. +Assignment of rights requires the other party's prior written consent. Transfer of obligations requires a written novation agreed by all affected parties. """ [clauses.notices] heading = "Notices" body = """ -Notices under this agreement must be in writing and sent to the email or address on the signature page, or as later notified in writing. +Notices must be in writing and sent to the contact email or postal address in the Parties section, or a replacement notified in writing. An email takes effect when receipt is acknowledged by the recipient, excluding automated responses. If no acknowledgement is received, the sender must use delivery with evidence of receipt. This clause does not govern service of court proceedings. """ [clauses.counterparts] -heading = "Counterparts and Electronic Signatures" +heading = "Counterparts and signatures" body = """ -This agreement may be signed in any number of counterparts, each of which is an original and which together form one agreement. Signatures exchanged electronically — including scanned and e-signed copies — are valid and binding. +This agreement may be signed in counterparts and by electronic signature where applicable law permits and all required execution formalities are met. Each signatory confirms authority to bind the party for whom they sign. A typed name recorded in administrative software is not, by itself, proof of the signatory's consent. """ [clauses.third_party_rights] -heading = "Third-Party Rights" +heading = "Third-party rights" body = """ -A person who is not a party to this agreement has no right to enforce any of its terms, whether under the Contracts (Rights of Third Parties) Act 1999 or otherwise. +A person who is not a party to this agreement has no right to enforce its terms, except where mandatory law provides otherwise. """ [clauses.no_partnership] @@ -151,8 +133,6 @@ body = """ Nothing in this agreement creates a partnership, joint venture, agency, or employment relationship between the parties. """ -# Optional clauses — not in the default set; add with --include. - [clauses.non_circumvention] heading = "Non-Circumvention" body = """ @@ -164,3 +144,9 @@ heading = "Non-Solicitation" body = """ While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact in connection with the Purpose. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. """ + +[clauses.protected_disclosures] +heading = "Protected disclosures" +body = """ +Nothing in this agreement prevents either party from reporting suspected crime to law enforcement, making a disclosure protected by applicable whistleblowing law, cooperating with a regulator, or obtaining confidential legal advice. No prior consent or notice is required where law prohibits that requirement. +""" diff --git a/clauses/service/standard.toml b/clauses/service/standard.toml index b668854..6467a96 100644 --- a/clauses/service/standard.toml +++ b/clauses/service/standard.toml @@ -1,20 +1,9 @@ [pack] slug = "standard" name = "Standard Service Agreement (plain English, ongoing)" -version = "1.0" +version = "2.0" kind = "service" -default_clauses = [ - "background", - "services", - "fees", - "term_renewal", - "support_changes", - "data_handling", - "warranties", - "liability", - "termination", - "general", -] +default_clauses = ["background", "services", "fees", "term_renewal", "support_changes", "data_handling", "warranties", "liability", "termination", "general", "confidentiality", "data_protection", "ip_ownership"] [clauses.background] heading = "Background" @@ -35,13 +24,13 @@ heading = "Fees" body = """ {{fee_text}}. -Fees are payable within 14 days of invoice. Late amounts may accrue interest at 1% per month. Taxes are added where the Provider is required to charge them. +Fees are payable within 14 days of invoice. Statutory interest and recovery costs may be claimed only to the extent available under applicable law; no additional contractual default interest is imposed. Taxes are added where the Provider is required to charge them. """ [clauses.term_renewal] heading = "Term and Renewal" body = """ -This agreement starts on {{effective_date}} and continues {{term_text}}. After the initial term, it renews for successive 12-month periods unless either party gives the other at least 30 days’ written notice before the end of the then-current term that it does not wish to renew. +This agreement starts on {{effective_date}} and continues {{term_text}}. A fixed term ends on expiry unless the parties expressly agree a renewal in writing, including its length, fees and notice requirements. There is no automatic renewal. An indefinite engagement may be terminated under the Termination clause. """ [clauses.support_changes] @@ -63,19 +52,45 @@ The Provider warrants that it will perform the services with reasonable skill an """ [clauses.liability] -heading = "Limitation of Liability" +heading = "Liability" body = """ -Neither party is liable to the other for indirect, consequential, or loss-of-profits damages. Each party’s total liability under this agreement is capped at the fees paid by the Customer in the twelve months before the event giving rise to the claim. Nothing in this clause limits liability that cannot be limited under applicable law. +Neither party is liable for indirect or consequential loss. Subject to the exclusions below, each party's total aggregate liability arising out of or in connection with this agreement, whether in contract, tort (including negligence), misrepresentation or otherwise, shall not exceed the total fees paid or payable under this agreement. This cap does not reduce the obligation to pay properly due fees. + +Nothing excludes or limits liability for death or personal injury caused by negligence, fraud or fraudulent misrepresentation, or any liability that applicable law does not permit to be excluded or limited. Any restriction applies only to the extent lawful and, where required, reasonable. A separate signed SOW may expressly agree a different cap for that SOW; it must identify this clause and the replacement cap. """ [clauses.termination] heading = "Termination" body = """ -Either party may terminate this agreement immediately for material breach not cured within 15 days of written notice, or if the other party becomes insolvent. The Customer may terminate for convenience by giving the Provider at least {{termination_notice_days}} days’ written notice. The Customer remains liable for fees up to the termination date. +Either party may terminate this agreement immediately for material breach not cured within 15 days of written notice, or, to the extent permitted by applicable insolvency law, if the other party becomes insolvent. The Customer may terminate for convenience by giving the Provider at least {{termination_notice_days}} days’ written notice. The Customer remains liable for fees up to the termination date. """ [clauses.general] -heading = "General" +heading = "General provisions" +body = """ +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. + +This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. +""" + +[clauses.confidentiality] +heading = "Confidentiality" +body = """ +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +""" + +[clauses.data_protection] +heading = "Data protection and security" +body = """ +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. + +Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. +""" + +[clauses.ip_ownership] +heading = "Intellectual property" body = """ -This agreement is the whole agreement on its subject. Changes must be in writing signed by both parties. If any part is unenforceable, the rest stays in force. Neither party may assign without the other’s consent, except to an affiliate or successor in connection with a sale of its business. Notices may be given by email to the address on the signature page. This agreement is governed by the laws of {{governing_law}} and the parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. +{{ip_assignment_text}} """ diff --git a/clauses/sow/standard.toml b/clauses/sow/standard.toml index 845bc09..4dc4df1 100644 --- a/clauses/sow/standard.toml +++ b/clauses/sow/standard.toml @@ -1,23 +1,14 @@ [pack] slug = "standard" name = "Standard Statement of Work (plain English)" -version = "1.0" +version = "2.0" kind = "sow" -default_clauses = [ - "reference", - "description", - "deliverables", - "fees", - "timing", - "acceptance", - "assumptions", - "signature_note", -] +default_clauses = ["reference", "description", "deliverables", "fees", "timing", "acceptance", "assumptions", "signature_note"] [clauses.reference] -heading = "Reference to Master Agreement" +heading = "Master agreement and precedence" body = """ -This Statement of Work (“SOW”) is entered into under the Master Services Agreement between {{our_legal_name}} (the “Provider”) and {{their_legal_name}} (the “Client”). The MSA’s terms apply to this SOW; where this SOW conflicts with the MSA, this SOW controls for this project. +This SOW is entered into by {{our_legal_name}} (the “Provider”) and {{their_legal_name}} (the “Client”) under the signed master services agreement identified as {{msa_reference}}. Its terms apply to this SOW. This SOW controls the project scope, deliverables, fees and schedule. It changes other MSA provisions only where it identifies the provision and states the agreed change expressly; otherwise the MSA prevails. """ [clauses.description] @@ -49,9 +40,9 @@ Work begins on {{effective_date}} and runs {{term_text}}. Specific milestones, i """ [clauses.acceptance] -heading = "Acceptance" +heading = "Review and acceptance" body = """ -Each deliverable is deemed accepted when (i) the Client confirms acceptance in writing (email is fine) or (ii) seven days after delivery, whichever is earlier, unless the Client reasonably rejects it in writing within that period describing what is materially missing or non-conforming. The Provider will then have a reasonable opportunity to correct it. +Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. Acceptance does not waive rights relating to latent defects or express warranties. Changes to agreed criteria or additional work require a written change order setting out fees and timing. """ [clauses.assumptions] diff --git a/docs/LEGAL.md b/docs/LEGAL.md new file mode 100644 index 0000000..5270b3c --- /dev/null +++ b/docs/LEGAL.md @@ -0,0 +1,47 @@ +# Legal scope and drafting sources + +The bundled wording is a starting point for two-party business agreements. The September 2026 update makes specific drafting improvements; it is not a lawyer's opinion or certification that any generated document is valid, complete or enforceable. A governing-law selection cannot override mandatory laws applying to a party, activity, worker, consumer or place of performance. + +## What profiles do + +`uk` selects England and Wales by default, with Scotland and Northern Ireland available. These are distinct legal systems. The same commercial pack is used; choosing Scotland does not rewrite the document for Scots execution rules or terminology. + +`us` requires one of the fifty states or District of Columbia and rejects a conflicting governing law. It uses state courts and federal courts where federal subject-matter jurisdiction exists. An explicit venue replaces that wording. A federal DTSA immunity notice is appended to current packs. State-specific employment, restrictive covenant, privacy, interest, consumer and contract rules are not comprehensively modelled. + +`singapore` selects Singapore law. UK-specific third-party-rights references and a fixed UK-style interest formula are not inserted into Singapore contracts. The generic data-processing provision is a prerequisite to a separate schedule, not a completed PDPA agreement. + +`global` requires an explicit governing law and court venue. It is a cross-border drafting mode, not a universal legal system or enforceability guarantee. International transfers, service abroad, recognition of judgments, sanctions, tax and local mandatory rules require transaction-specific review. Arbitration requires a separately drafted clause, not the court-venue flag. + +The choice of profile does not migrate old clause packs. Historical wording remains at its recorded version; it must be deliberately reviewed when duplicating or renegotiating a contract. + +## Drafting decisions + +- Liability caps cover fees paid or payable and preserve mandatory liabilities, including fraud and negligence causing death or personal injury. A cap may still be unreasonable or unsuitable; review insurance, exposure and any separate IP, security or confidentiality cap. +- IP provisions distinguish final deliverables from background IP and third-party assets, tie transfer to payment, require personnel rights and support further documentation. Shared ownership requires a separate schedule. Local signed-writing formalities and moral rights still need attention. +- Confidentiality includes ordinary exclusions, trade-secret survival, protected reporting and regulator/legal-adviser disclosures. The US notice is based on 18 USC 1833(b), whose employee definition includes qualifying contractors and consultants. +- NCNDA protection is tied to recorded contacts and a specified opportunity, an agreed role or fee, and a limited period. It permits independent business. Optional non-solicitation and non-circumvention clauses in other packs still need particular local review; enabling one does not establish a lawful restraint. No implied introducer fee or authorisation for regulated activity is created. +- Email notice provisions use acknowledged receipt and a delivery fallback; they do not govern service of court proceedings. Signature provisions preserve applicable formalities and signatory authority. +- Services packs require a separate processing schedule before processing personal data for the other party. AI use of the other party's confidential information needs written authorisation and agreed controls. These provisions do not replace the required schedule, security assessment or transfer mechanism. +- Technology acceptance is assessed against agreed criteria; silence alone does not constitute acceptance. Design scope needs a revision allowance, delivery formats and asset licences. An MSA or startup pack needs project SOWs. A SOW must identify its parent MSA and any intended deviations expressly. +- The loan pack does not supply consumer-credit, moneylending or financial-services permissions, disclosures or prescribed documents. Review borrower status, interest, security, local licensing, acceleration, set-off and recovery costs before use. A regulatory-scope clause does not cure a regulated loan. + +## Primary sources consulted + +Sources were consulted on 10 September 2026. They support the identified drafting issue; they are not a full survey of current law. Check the current legislation and commencement provisions for the transaction date. + +| Source | Relevant issue | +|---|---| +| [UK Unfair Contract Terms Act 1977, section 2](https://www.legislation.gov.uk/ukpga/1977/50/section/2) | Negligence exclusions and the statutory reasonableness boundary. | +| [UK Copyright, Designs and Patents Act 1988, sections 90–91](https://www.legislation.gov.uk/ukpga/1988/48/section/90) | Signed-writing requirements for copyright assignment; future rights need appropriate treatment. | +| [UK late commercial payments guidance](https://www.gov.uk/late-commercial-payments-interest-debt-recovery/charging-interest-commercial-debt) | Statutory interest depends on applicable law and transaction circumstances. | +| [ICO: what processor contracts need to include](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/accountability-and-governance/contracts-and-liabilities-between-controllers-and-processors-multi/what-needs-to-be-included-in-the-contract/) | Article 28 processing particulars, instructions, security, subprocessors, assistance and audit requirements. Guidance is under review following the Data (Use and Access) Act 2025. | +| [Singapore Unfair Contract Terms Act](https://sso.agc.gov.sg/Act/UCTA1977) | Limits on exclusions and reasonableness requirements. | +| [Singapore Electronic Transactions Act 2010](https://sso.agc.gov.sg/Act/88) | Electronic records/signatures and excluded matters; electronic form alone is not a complete execution analysis. | +| [PDPC guide to data-protection clauses](https://www.pdpc.gov.sg/help-and-resources/2017/10/guide-on-data-protection-clauses-for-agreements-relating-to-the-processing-of-personal-data) | Organisation and data-intermediary obligations need an appropriate processing agreement. | +| [18 USC 1833(b)](https://uscode.house.gov/view.xhtml?req=%28title%3A18+section%3A1833+edition%3Aprelim%29) | Trade-secret reporting immunity, retaliation proceedings and employer notice to employees, including qualifying consultants and contractors. | + +## Review and execution + +Confirm parties, authority, purpose, scope, fees, payment events, dates, deliverables, background IP, data flows, liability and dispute resolution. Resolve the document's placeholders and all referenced schedules. A clean render only checks unresolved variable tokens; it does not verify those commercial facts. Manually changing status or recording a name is not evidence of consent. + +Use a suitable signing process and retain the executed document with its evidence. This tool does not provide identity checks, witness workflows, deeds, notarisation, regulated-document forms, multi-party execution or cryptographic signatures. Saved pack versions protect source wording from silent pack upgrades; they do not make later renders identical to an earlier signed PDF. diff --git a/scripts/smoke-pdfs.py b/scripts/smoke-pdfs.py new file mode 100644 index 0000000..ce3b297 --- /dev/null +++ b/scripts/smoke-pdfs.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Render and inspect the complete embedded contract PDF matrix.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unicodedata + + +EXPECTED_TEMPLATES = { + "atelier", + "basel", + "chancery", + "counsel", + "editorial", + "folio", + "gazette", + "helvetica-nera", + "marrakech", + "vienna-legal", +} + +KIND_MARKERS = { + "nda": "potential collaboration on a new software product", + "ncnda": "neither party is cut out of the introductions made under it", + "consulting": "design and delivery of a customer-facing dashboard", + "msa": "specific projects are described in separate statements of work", + "sow": "acceptance shall be assessed against the deliverables and objective acceptance criteria", + "service": "commercially reasonable efforts to keep the services available", + "loan": "borrower may repay the loan early, in whole or in part", +} + +PACK_CASES = ( + ( + "consulting", + "technology", + "reasonable secure development practices", + ), + ( + "consulting", + "design", + "no portfolio permission is implied by payment", + ), + ( + "msa", + "startup", + "no equity, options, revenue share or success fee is granted", + ), +) + + +class SmokeFailure(RuntimeError): + pass + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render and inspect all embedded contract templates and kinds." + ) + parser.add_argument( + "--binary", + default="target/debug/contract", + help="contract executable (default: target/debug/contract)", + ) + return parser.parse_args() + + +def resolve_binary(value: str) -> Path: + candidate = Path(value).expanduser() + if candidate.is_file(): + return candidate.resolve() + found = shutil.which(value) + if found: + return Path(found).resolve() + raise SmokeFailure(f"contract binary not found: {value}") + + +def require_tool(name: str) -> str: + path = shutil.which(name) + if not path: + raise SmokeFailure(f"required command not found on PATH: {name}") + return path + + +def run_checked(command: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "no output" + raise SmokeFailure(f"command failed ({result.returncode}): {' '.join(command)}\n{detail}") + return result + + +def cli_json(binary: Path, env: dict[str, str], args: list[str]) -> object: + result = run_checked([str(binary), "--json", *args], env=env) + try: + envelope = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise SmokeFailure(f"contract returned invalid JSON for {args}: {error}") from error + if envelope.get("status") != "success": + raise SmokeFailure(f"contract returned a non-success envelope for {args}") + return envelope.get("data") + + +def normalized(text: str) -> str: + return " ".join(unicodedata.normalize("NFKC", text).lower().split()) + + +def inspect_pdf(pdf: Path, pdftotext: str, markers: tuple[str, ...]) -> None: + if not pdf.is_file(): + raise SmokeFailure(f"preview was not created: {pdf}") + with pdf.open("rb") as handle: + if handle.read(5) != b"%PDF-": + raise SmokeFailure(f"invalid PDF header: {pdf.name}") + + result = run_checked([pdftotext, "-enc", "UTF-8", str(pdf), "-"]) + text = normalized(result.stdout) + if "{{" in text or "}}" in text: + raise SmokeFailure(f"unresolved template variable in {pdf.name}") + for marker in markers: + if normalized(marker) not in text: + raise SmokeFailure(f"missing clause text in {pdf.name}: {marker!r}") + + +def render_preview( + binary: Path, + env: dict[str, str], + pdftotext: str, + output_dir: Path, + template: str, + kind: str, + *, + pack: str = "standard", + paper: str = "a4", + extra_marker: str | None = None, +) -> Path: + filename = f"{template}-{kind}-{pack}-{paper}.pdf" + pdf = output_dir / filename + cli_json( + binary, + env, + [ + "template", + "preview", + template, + "--kind", + kind, + "--pack", + pack, + "--paper", + paper, + "--out", + str(pdf), + ], + ) + markers = (KIND_MARKERS[kind],) if extra_marker is None else (KIND_MARKERS[kind], extra_marker) + inspect_pdf(pdf, pdftotext, markers) + return pdf + + +def check_us_letter(pdf: Path, pdfinfo: str) -> None: + result = run_checked([pdfinfo, str(pdf)]) + match = re.search(r"^Page size:\s+([0-9.]+) x ([0-9.]+) pts", result.stdout, re.MULTILINE) + if not match: + raise SmokeFailure(f"pdfinfo did not report page dimensions for {pdf.name}") + width, height = (float(value) for value in match.groups()) + if abs(width - 612.0) > 1.0 or abs(height - 792.0) > 1.0: + raise SmokeFailure( + f"{pdf.name} is {width:g} x {height:g} pt; expected US Letter 612 x 792 pt" + ) + + +def isolated_environment(root: Path) -> dict[str, str]: + home = root / "home" + config = root / "config" + data = root / "data" + cache = root / "cache" + for directory in (home, config, data, cache): + directory.mkdir() + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "XDG_CONFIG_HOME": str(config), + "XDG_DATA_HOME": str(data), + "XDG_CACHE_HOME": str(cache), + } + ) + return env + + +def check_custom_content(binary: Path, env: dict[str, str], pdftotext: str, root: Path) -> None: + cli_json(binary, env, ["issuer", "add", "example", "--name", "Example Studio", "--jurisdiction", "uk", "--address", "1 Example Street"]) + cli_json(binary, env, ["clients", "add", "client", "--name", "Example Client", "--address", "2 Example Street"]) + record = cli_json(binary, env, ["new", "--kind", "nda", "--as", "example", "--client", "client", "--legal-profile", "us", "--us-state", "Delaware", "--purpose", "A synthetic render regression", "--notes", "PRIVATE_NOTE_SENTINEL"]) + number = record["number"] + markers = ["Paragraph sentinel", "Bullet continuation sentinel", "Number continuation sentinel", "Final paragraph sentinel"] + body = "Paragraph sentinel. Literal #panic(42) must remain text.\n\n- Bullet start\n Bullet continuation sentinel\n\n1. Number start\n Number continuation sentinel\n\n" + for index in range(40): + marker = f"Long item {index:03d} complete" + markers.append(marker) + body += f"- {marker}. " + "Additional agreed scope details remain readable across page breaks. " * 3 + "\n" + body += "\nFinal paragraph sentinel." + source = root / "clause.md" + source.write_text(body) + cli_json(binary, env, ["contracts", "clauses", "add", number, "regression", "--heading", "Additional terms", "--from-file", str(source)]) + pdf = root / "custom.pdf" + cli_json(binary, env, ["render", number, "--template", "folio", "--final", "--out", str(pdf)]) + inspect_pdf(pdf, pdftotext, tuple(markers + ["report or investigate a suspected violation of law"])) + contents = run_checked([pdftotext, str(pdf), "-"]).stdout + if "PRIVATE_NOTE_SENTINEL" in contents: + raise SmokeFailure("private notes entered the PDF") + original = pdf.read_bytes() + + # Force a real compiler failure while preserving the existing destination. + fake_bin = root / "fake-bin" + fake_bin.mkdir() + fake_typst = fake_bin / "typst" + fake_typst.write_text("#!/bin/sh\nprintf 'synthetic compiler failure' >&2\nexit 1\n") + fake_typst.chmod(0o755) + failed_env = dict(env, PATH=str(fake_bin) + os.pathsep + env.get("PATH", "")) + failed = subprocess.run([str(binary), "--json", "render", number, "--template", "folio", "--final", "--out", str(pdf)], env=failed_env, capture_output=True, text=True) + if failed.returncode == 0 or pdf.read_bytes() != original or failed.stdout: + raise SmokeFailure("compiler failure replaced output or contaminated stdout") + json.loads(failed.stderr) + + cli_json(binary, env, ["contracts", "clauses", "edit", number, "regression", "--body", "Required {{missing_term}}"]) + failed = subprocess.run([str(binary), "--json", "render", number, "--template", "folio", "--final", "--out", str(pdf)], env=env, capture_output=True, text=True) + if failed.returncode != 3 or pdf.read_bytes() != original: + raise SmokeFailure("unresolved term did not block a clean render") + + +def main() -> int: + args = parse_args() + binary = resolve_binary(args.binary) + require_tool("typst") + pdftotext = require_tool("pdftotext") + pdfinfo = shutil.which("pdfinfo") + + with tempfile.TemporaryDirectory(prefix="contract-cli-pdf-smoke-") as temporary: + root = Path(temporary) + output_dir = root / "pdfs" + output_dir.mkdir() + env = isolated_environment(root) + + listing = cli_json(binary, env, ["template", "list"]) + if not isinstance(listing, list): + raise SmokeFailure("template list data is not an array") + templates = [item.get("name") for item in listing if isinstance(item, dict)] + if len(templates) != 10 or set(templates) != EXPECTED_TEMPLATES: + raise SmokeFailure( + f"expected 10 embedded templates {sorted(EXPECTED_TEMPLATES)}, got {sorted(templates)}" + ) + + rendered = 0 + for template in sorted(templates): + for kind in KIND_MARKERS: + render_preview(binary, env, pdftotext, output_dir, template, kind) + rendered += 1 + + for kind, pack, marker in PACK_CASES: + render_preview( + binary, + env, + pdftotext, + output_dir, + "folio", + kind, + pack=pack, + extra_marker=marker, + ) + rendered += 1 + + letter_pdf = render_preview( + binary, + env, + pdftotext, + output_dir, + "folio", + "consulting", + paper="us-letter", + ) + rendered += 1 + if pdfinfo: + check_us_letter(letter_pdf, pdfinfo) + check_custom_content(binary, env, pdftotext, root) + rendered += 1 + + dimension_status = "checked" if pdfinfo else "skipped (pdfinfo unavailable)" + print( + f"OK: {rendered} PDFs; 10 templates x 7 kinds, 3 pack previews, " + f"1 US Letter ({dimension_status}), 1 long custom document; failure/notes checks passed" + ) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SmokeFailure as error: + print(f"FAIL: {error}", file=sys.stderr) + sys.exit(1) diff --git a/src/clauses.rs b/src/clauses.rs index 043f6b8..85bee99 100644 --- a/src/clauses.rs +++ b/src/clauses.rs @@ -56,8 +56,20 @@ pub fn load_pack(kind: &str, pack_slug: &str) -> Result { .ok_or_else(|| AppError::NotFound(format!("clause pack '{kind}/{pack_slug}'")))?; let text = std::str::from_utf8(&file.data) .map_err(|e| AppError::Other(format!("non-utf8 pack {path}: {e}")))?; - toml::from_str::(text) - .map_err(|e| AppError::Other(format!("invalid pack {path}: {e}"))) + toml::from_str::(text).map_err(|e| AppError::Other(format!("invalid pack {path}: {e}"))) +} + +pub fn load_pack_version(kind: &str, slug: &str, version: &str) -> Result { + let current = load_pack(kind, slug)?; + if current.pack.version == version { + return Ok(current); + } + let path = format!("archive/{kind}/{slug}-{version}.toml"); + let file = PackAssets::get(&path).ok_or_else(|| AppError::InvalidInput(format!( + "clause pack {kind}/{slug} version {version} is unavailable; refusing to substitute different legal wording" + )))?; + let text = std::str::from_utf8(&file.data).map_err(|e| AppError::Other(e.to_string()))?; + toml::from_str(text).map_err(|e| AppError::Other(format!("invalid historical pack: {e}"))) } fn humanize_slug(slug: &str) -> String { @@ -83,6 +95,9 @@ pub fn list_packs() -> Vec<(String, String)> { .filter_map(|p| { let p = p.as_ref(); let (kind, file) = p.split_once('/')?; + if kind == "archive" { + return None; + } let pack_slug = file.strip_suffix(".toml")?; Some((kind.to_string(), pack_slug.to_string())) }) @@ -136,10 +151,7 @@ pub fn resolve( let mut out = Vec::with_capacity(included.len()); for (i, slug) in included.iter().enumerate() { let def = pack.clauses.get(slug); - let (head_override, body_override) = overrides - .get(slug) - .cloned() - .unwrap_or((None, None)); + let (head_override, body_override) = overrides.get(slug).cloned().unwrap_or((None, None)); // Custom clauses (added with --body / --from-file) don't exist in the // pack — their heading/body live entirely in the override. let heading = match (head_override, def) { @@ -154,7 +166,7 @@ pub fn resolve( return Err(AppError::NotFound(format!( "clause '{slug}' is not in the pack and has no custom body. \ Set one with: contract clauses edit {slug} --body \"…\"" - ))) + ))); } }; out.push(ResolvedClause { diff --git a/src/cli.rs b/src/cli.rs index aee09b2..24af986 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ Tips: • Run `contract agent-info | jq` for the full capability manifest (commands, flags, exit codes) • The DB is SHARED with invoice-cli — always `contract issuer list` / `contract clients list` before creating entities • Pipe any command to jq for structured data: `contract list | jq '.data'` - • Template chain at render: --template > contract.default_template > \"helvetica-nera\" + • Template chain at render: --template > contract.default_template > valid shared config default > \"helvetica-nera\" • Drafts render with a DRAFT watermark; use --final for a clean signing copy • `contract doctor` verifies typst, the DB, packs, and templates before you start @@ -20,7 +20,7 @@ Examples: contract render NDA-acme-2026-0001 --final --open Render a clean, watermark-free PDF and open it - contract sign NDA-acme-2026-0001 --side us --name 'B. Djordjevic' --title Director + contract sign NDA-acme-2026-0001 --side us --name 'Alex Morgan' --title Director Record one side's signature (status auto-bumps to 'signed' when both sides sign) contract template list | jq '.data' @@ -287,6 +287,12 @@ pub struct ContractNewArgs { /// Governing law (e.g. "Singapore", "England and Wales", "Delaware") #[arg(long)] pub governing_law: Option, + /// Legal profile: global | uk | us | singapore. Global requires law and venue; US requires --us-state. + #[arg(long, value_parser = ["global", "uk", "us", "singapore"])] + pub legal_profile: Option, + /// US state governing the contract (e.g. Delaware or New York) + #[arg(long, requires = "legal_profile")] + pub us_state: Option, /// Court venue (e.g. "Courts of Singapore") #[arg(long)] pub venue: Option, @@ -332,7 +338,7 @@ pub struct ContractNewArgs { /// Free-form notes (not rendered on the contract body, kept for reference) #[arg(long)] pub notes: Option, - /// Override the rendered template (else config default) + /// Default render template for this contract #[arg(long)] pub template: Option, } @@ -356,6 +362,9 @@ pub struct ContractRenderArgs { /// Output path (defaults to issuer default_output_dir / ./contract-.pdf) #[arg(long, short)] pub out: Option, + /// Page size: a4 or us-letter + #[arg(long, value_parser = ["a4", "us-letter"], default_value = "a4")] + pub paper: String, /// Open the PDF after rendering #[arg(long)] pub open: bool, @@ -402,6 +411,10 @@ pub struct ContractEditArgs { pub term_months: Option, #[arg(long)] pub governing_law: Option, + #[arg(long, value_parser = ["global", "uk", "us", "singapore"])] + pub legal_profile: Option, + #[arg(long, requires = "legal_profile")] + pub us_state: Option, #[arg(long)] pub venue: Option, #[arg(long)] @@ -435,6 +448,8 @@ pub struct DeleteArgs { } #[derive(Subcommand, Debug)] +// Parsed once per invocation; boxing would add allocation without a useful runtime benefit. +#[allow(clippy::large_enum_variant)] pub enum ContractCmd { /// Create a new contract #[command(visible_alias = "create")] @@ -541,6 +556,11 @@ pub enum TemplateCmd { /// Render a preview contract with synthetic data Preview { name: String, + /// Clause pack to preview + #[arg(long, default_value = "standard")] + pack: String, + #[arg(long, value_parser = ["a4", "us-letter"], default_value = "a4")] + paper: String, /// Which contract kind to preview (default consulting) #[arg(long, default_value = "consulting")] kind: String, diff --git a/src/commands/agent_info.rs b/src/commands/agent_info.rs index 8c7265c..f12581f 100644 --- a/src/commands/agent_info.rs +++ b/src/commands/agent_info.rs @@ -6,7 +6,7 @@ use serde_json::json; use crate::error::Result; -use crate::output::{print_raw, Ctx}; +use crate::output::{Ctx, print_raw}; fn arg(name: &str, ty: &str, required: bool, desc: &str) -> serde_json::Value { json!({ "name": name, "kind": "positional", "type": ty, "required": required, "description": desc }) @@ -20,7 +20,12 @@ fn req_opt(name: &str, ty: &str, desc: &str) -> serde_json::Value { json!({ "name": name, "type": ty, "required": true, "description": desc }) } -fn cmd(desc: &str, aliases: &[&str], args: Vec, options: Vec) -> serde_json::Value { +fn cmd( + desc: &str, + aliases: &[&str], + args: Vec, + options: Vec, +) -> serde_json::Value { let mut v = json!({ "description": desc, "args": args, "options": options }); if !aliases.is_empty() { v["aliases"] = json!(aliases); @@ -34,22 +39,45 @@ pub fn run(_ctx: Ctx) -> Result<()> { let database = crate::config::db_path()?.display().to_string(); let slug = |d: &str| vec![arg("slug", "string", true, d)]; - let number = || vec![arg("number", "string", true, "Contract number, e.g. NDA-acme-2026-0001")]; + let number = || { + vec![arg( + "number", + "string", + true, + "Contract number, e.g. NDA-acme-2026-0001", + )] + }; let entity_opts = |contract_side: bool| { let mut o = vec![ opt("--name", "string", "Display name"), - opt("--legal-name", "string", "Legal entity name used on contracts"), + opt( + "--legal-name", + "string", + "Legal entity name used on contracts", + ), opt("--company-no", "string", "Registration / company number"), - opt("--jurisdiction", "string", "Jurisdiction (issuers: sg|uk|us|eu; clients: free text)"), + opt( + "--jurisdiction", + "string", + "Jurisdiction (issuers: sg|uk|us|eu; clients: free text)", + ), opt("--address", "string", "Address lines separated by \\n"), opt("--email", "string", "Contact email"), ]; if contract_side { o.push(opt("--tax-id", "string", "Tax / VAT id")); o.push(opt("--phone", "string", "Phone")); - o.push(opt("--logo", "string", "Path to logo image for the contract header")); - o.push(opt("--output-dir", "string", "Default output dir for render")); + o.push(opt( + "--logo", + "string", + "Path to logo image for the contract header", + )); + o.push(opt( + "--output-dir", + "string", + "Default output dir for render", + )); } else { o.push(opt("--attn", "string", "Attention line")); o.push(opt("--country", "string", "ISO country code")); @@ -59,34 +87,105 @@ pub fn run(_ctx: Ctx) -> Result<()> { }; let new_opts = vec![ - req_opt("--kind", "string", "nda | ncnda | consulting | msa | sow | service | loan"), - opt("--as", "string", "Issuer slug (your side); falls back to client default, then config"), + req_opt( + "--kind", + "string", + "nda | ncnda | consulting | msa | sow | service | loan", + ), + opt( + "--as", + "string", + "Issuer slug (your side); falls back to client default, then config", + ), req_opt("--client", "string", "Client slug (counterparty)"), opt("--title", "string", "Contract title (defaults per kind)"), - opt("--effective", "string", "Effective date YYYY-MM-DD (default today)"), - opt("--end", "string", "End date YYYY-MM-DD (mutually exclusive with --term-months)"), + opt( + "--effective", + "string", + "Effective date YYYY-MM-DD (default today)", + ), + opt( + "--end", + "string", + "End date YYYY-MM-DD (mutually exclusive with --term-months)", + ), opt("--term-months", "int", "Term length in months"), - opt("--term-years", "int", "Term length in years (sugar for months × 12)"), - opt("--governing-law", "string", "Governing law, e.g. 'England and Wales'"), + opt( + "--term-years", + "int", + "Term length in years (sugar for months × 12)", + ), + opt("--legal-profile", "string", "global | uk | us | singapore"), + opt( + "--us-state", + "string", + "Full US state name; required with --legal-profile us", + ), + opt( + "--governing-law", + "string", + "Governing law, e.g. 'England and Wales'", + ), opt("--venue", "string", "Court venue override"), - opt("--fee", "string", "type:amount:currency — fixed:8400:SGD | hourly:200:SGD | daily:1500:SGD | retainer:5000:SGD"), - opt("--fee-schedule", "string", "on-completion | monthly | on-milestone | upon-invoice"), - opt("--mutuality", "string", "nda/ncnda: mutual | unilateral"), + opt( + "--fee", + "string", + "type:amount:currency — fixed:8400:SGD | hourly:200:SGD | daily:1500:SGD | retainer:5000:SGD", + ), + opt( + "--fee-schedule", + "string", + "on-completion | monthly | on-milestone | upon-invoice", + ), + opt( + "--mutuality", + "string", + "nda: mutual | unilateral; ncnda: mutual", + ), opt("--disclosing-side", "string", "nda: us | them | both"), opt("--purpose", "string", "Purpose / scope summary"), opt("--deliverable", "string", "Deliverable line (repeatable)"), - opt("--ip-assignment", "string", "client | consultant | shared"), - opt("--termination-notice-days", "int", "Notice days for termination for convenience"), - opt("--term", "string", "Arbitrary term key=value for pack {{vars}} (repeatable), e.g. principal_text='£10,000 (ten thousand pounds)'"), + opt( + "--ip-assignment", + "string", + "client | consultant | provider | shared", + ), + opt( + "--termination-notice-days", + "int", + "Notice days for termination for convenience", + ), + opt( + "--term", + "string", + "Arbitrary term key=value for pack {{vars}} (repeatable), e.g. principal_text='£10,000 (ten thousand pounds)'", + ), opt("--pack", "string", "Clause pack slug (default: standard)"), - opt("--include", "string", "Extra pack clause slug to include (repeatable)"), - opt("--exclude", "string", "Default pack clause slug to drop (repeatable)"), - opt("--template", "string", "Default render template for this contract"), + opt( + "--include", + "string", + "Extra pack clause slug to include (repeatable)", + ), + opt( + "--exclude", + "string", + "Default pack clause slug to drop (repeatable)", + ), + opt( + "--template", + "string", + "Default render template for this contract", + ), opt("--notes", "string", "Internal notes (never rendered)"), ]; let render_opts = vec![ - opt("--template", "string", "Template override (see: template list)"), + opt( + "--template", + "string", + "Template override (see: template list)", + ), + opt("--paper", "string", "a4 | us-letter (default a4)"), opt("--out", "string", "Output PDF path"), opt("--open", "bool", "Open the PDF after rendering"), opt("--draft", "bool", "Force the DRAFT watermark"), @@ -121,6 +220,8 @@ pub fn run(_ctx: Ctx) -> Result<()> { opt("--effective", "string", "Effective date YYYY-MM-DD"), opt("--end", "string", "End date (clears term-months)"), opt("--term-months", "int", "Term months (clears end date)"), + opt("--legal-profile", "string", "global | uk | us | singapore"), + opt("--us-state", "string", "Full US state name; required with --legal-profile us"), opt("--governing-law", "string", "Governing law"), opt("--venue", "string", "Venue"), opt("--fee", "string", "type:amount:currency"), @@ -186,6 +287,8 @@ pub fn run(_ctx: Ctx) -> Result<()> { ], vec![]), "template preview": cmd("Render a sample contract PDF with synthetic data", &[], vec![arg("name", "string", true, "Template name")], vec![ opt("--kind", "string", "Contract kind to preview (default consulting)"), + opt("--pack", "string", "Clause pack to preview (default standard)"), + opt("--paper", "string", "a4 | us-letter (default a4)"), opt("--out", "string", "Output path"), ]), "kinds list": cmd("List contract kinds with descriptions and trigger tags", &["kinds ls", "kind list"], vec![], vec![]), @@ -267,17 +370,20 @@ pub fn run(_ctx: Ctx) -> Result<()> { { "goal": "Quick mutual NDA", "command": "contract new --kind nda --as acme --client meridian --purpose 'evaluation of a joint product' --term-years 3" }, { "goal": "Non-circumvention agreement protecting an introduction", - "command": "contract new --kind ncnda --as boris --client partner --purpose 'introduction to prospective lenders for the transaction' --term-years 2" }, + "command": "contract new --kind ncnda --as acme --client partner --purpose 'introduction to prospective lenders for the transaction' --term-years 2" }, { "goal": "Consulting agreement with fixed fee", "command": "contract new --kind consulting --as acme --client meridian --purpose 'design a dashboard' --fee fixed:8400:SGD --term-months 3 --deliverable 'Design' --deliverable 'Build'" }, { "goal": "Interest-free loan with fixed repayment date", - "command": "contract new --kind loan --as boris --client friend --term principal_text='£10,000 (ten thousand pounds sterling)' --term repayment_date=2026-12-01 --term interest_text='interest-free'" }, + "command": "contract new --kind loan --as acme --client friend --term principal_text='£10,000 (ten thousand pounds sterling)' --term repayment_date=2026-12-01 --term interest_text='interest-free'" }, { "goal": "Pick a template by describing the look", "command": "contract template find \"magazine masthead serif\"" }, { "goal": "Render with no watermark", "command": "contract render NDA-acme-2026-0001 --final --open" }, ], + "legal_profiles": ["global", "uk", "us", "singapore"], "guardrails": [ + "Legal profiles select law, court wording and limited notices; they do not certify compliance. Global requires an explicit governing law and venue.", + "sign records administrative metadata. It does not collect consent, authenticate a signer, send documents or create an electronic-signature audit trail.", "BEFORE creating any issuer or client, run `contract issuer list` and `contract clients list` — the DB is shared with invoice-cli; duplicates pollute both tools.", "Run doctor before first use.", "Use --json for agents; stdout is data, stderr is diagnostics.", diff --git a/src/commands/clauses.rs b/src/commands/clauses.rs index 4bfdc11..8dfc443 100644 --- a/src/commands/clauses.rs +++ b/src/commands/clauses.rs @@ -1,8 +1,8 @@ -use crate::cli::ClauseCmd; use crate::clauses; +use crate::cli::ClauseCmd; use crate::db::{self, ContractClauseRow}; use crate::error::{AppError, Result}; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; pub fn run(cmd: ClauseCmd, ctx: Ctx) -> Result<()> { match cmd { @@ -38,9 +38,11 @@ fn body_text(body: Option, from_file: Option) -> Result Ok(Some(b)), - (None, Some(p)) => Ok(Some(std::fs::read_to_string(&p).map_err(|e| { - AppError::InvalidInput(format!("could not read {p}: {e}")) - })?)), + (None, Some(p)) => { + Ok(Some(std::fs::read_to_string(&p).map_err(|e| { + AppError::InvalidInput(format!("could not read {p}: {e}")) + })?)) + } (None, None) => Ok(None), } } @@ -84,7 +86,7 @@ fn add( let body = body_text(body, from_file)?; // Validate slug exists in the pack OR a custom body was provided. let c = db::contract_get_or_404(&conn, number)?; - let pack = clauses::load_pack(&c.kind, &c.clause_pack)?; + let pack = clauses::load_pack_version(&c.kind, &c.clause_pack, &c.clause_pack_version)?; if body.is_none() && !pack.clauses.contains_key(slug) { return Err(AppError::NotFound(format!( "clause '{slug}' is not in pack '{}/{}'. Either pick a known slug or pass --body / --from-file to define a custom clause.", @@ -97,7 +99,14 @@ fn add( } else { heading }; - let row = db::clause_add(&mut conn, number, slug, heading.as_deref(), body.as_deref(), position)?; + let row = db::clause_add( + &mut conn, + number, + slug, + heading.as_deref(), + body.as_deref(), + position, + )?; print_success(ctx, &row, |r| { println!("added clause '{}' at position {}", r.slug, r.position + 1); }); @@ -157,7 +166,7 @@ fn move_clause(number: &str, slug: &str, position: i64, ctx: Ctx) -> Result<()> fn reset(number: &str, ctx: Ctx) -> Result<()> { let mut conn = db::open()?; let c = db::contract_get_or_404(&conn, number)?; - let pack = clauses::load_pack(&c.kind, &c.clause_pack)?; + let pack = clauses::load_pack_version(&c.kind, &c.clause_pack, &c.clause_pack_version)?; let fresh: Vec = pack .pack .default_clauses diff --git a/src/commands/clients.rs b/src/commands/clients.rs index b857e90..016d9eb 100644 --- a/src/commands/clients.rs +++ b/src/commands/clients.rs @@ -1,7 +1,7 @@ use crate::cli::ClientCmd; use crate::db::{self, Client}; use crate::error::Result; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; use super::split_multiline_arg; @@ -39,7 +39,9 @@ pub fn run(cmd: ClientCmd, ctx: Ctx) -> Result<()> { let id = db::client_create(&conn, &c)?; let mut saved = c; saved.id = id; - print_success(ctx, &saved, |c| println!("added client '{}' (#{})", c.slug, c.id)); + print_success(ctx, &saved, |c| { + println!("added client '{}' (#{})", c.slug, c.id) + }); Ok(()) } ClientCmd::Edit { @@ -55,15 +57,33 @@ pub fn run(cmd: ClientCmd, ctx: Ctx) -> Result<()> { notes, } => { let mut existing = db::client_by_slug(&conn, &slug)?; - if let Some(v) = name { existing.name = v; } - if let Some(v) = legal_name { existing.legal_name = Some(v); } - if let Some(v) = company_no { existing.company_no = Some(v); } - if let Some(v) = jurisdiction { existing.legal_jurisdiction = Some(v); } - if let Some(v) = attn { existing.attn = Some(v); } - if let Some(v) = country { existing.country = Some(v); } - if let Some(v) = address { existing.address = split_multiline_arg(&v); } - if let Some(v) = email { existing.email = Some(v); } - if let Some(v) = notes { existing.notes = Some(v); } + if let Some(v) = name { + existing.name = v; + } + if let Some(v) = legal_name { + existing.legal_name = Some(v); + } + if let Some(v) = company_no { + existing.company_no = Some(v); + } + if let Some(v) = jurisdiction { + existing.legal_jurisdiction = Some(v); + } + if let Some(v) = attn { + existing.attn = Some(v); + } + if let Some(v) = country { + existing.country = Some(v); + } + if let Some(v) = address { + existing.address = split_multiline_arg(&v); + } + if let Some(v) = email { + existing.email = Some(v); + } + if let Some(v) = notes { + existing.notes = Some(v); + } db::client_update(&conn, &existing)?; print_success(ctx, &existing, |c| println!("updated client '{}'", c.slug)); Ok(()) @@ -72,7 +92,9 @@ pub fn run(cmd: ClientCmd, ctx: Ctx) -> Result<()> { let list = db::client_list(&conn)?; print_success(ctx, &list, |rows| { if rows.is_empty() { - println!("(no clients — add one with: contract clients add --name X --address ...)"); + println!( + "(no clients — add one with: contract clients add --name X --address ...)" + ); } else { for c in rows { println!( diff --git a/src/commands/config.rs b/src/commands/config.rs index f0af367..5ffdc54 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,7 +1,7 @@ use crate::cli::ConfigCmd; use crate::config; use crate::error::{AppError, Result}; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; fn parse_bool(key: &str, value: &str) -> Result { match value.to_lowercase().as_str() { @@ -17,11 +17,9 @@ pub fn run(cmd: ConfigCmd, ctx: Ctx) -> Result<()> { match cmd { ConfigCmd::Show => { let cfg = config::load()?; - print_success(ctx, &cfg, |c| { - match toml::to_string_pretty(c) { - Ok(t) => print!("{t}"), - Err(_) => println!("{c:?}"), - } + print_success(ctx, &cfg, |c| match toml::to_string_pretty(c) { + Ok(t) => print!("{t}"), + Err(_) => println!("{c:?}"), }); Ok(()) } @@ -35,9 +33,18 @@ pub fn run(cmd: ConfigCmd, ctx: Ctx) -> Result<()> { let mut cfg = config::load()?; match key.as_str() { "default_issuer" => { - cfg.default_issuer = if value == "unset" { None } else { Some(value.clone()) }; + cfg.default_issuer = if value == "unset" { + None + } else { + Some(value.clone()) + }; } "default_template" => { + if !crate::typst_assets::has_template(&value)? { + return Err(AppError::InvalidInput(format!( + "unknown contract template {value}; run contract template list" + ))); + } cfg.default_template = value.clone(); } "open_pdf" => { diff --git a/src/commands/contracts.rs b/src/commands/contracts.rs index 18aa056..7b2b57a 100644 --- a/src/commands/contracts.rs +++ b/src/commands/contracts.rs @@ -3,13 +3,13 @@ use std::path::PathBuf; use std::process::Command; use chrono::{Datelike, NaiveDate}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; -use crate::cli::{ContractCmd, ContractListArgs, ContractNewArgs, ContractRenderArgs, SignArgs}; use crate::clauses; +use crate::cli::{ContractCmd, ContractListArgs, ContractNewArgs, ContractRenderArgs, SignArgs}; use crate::db::{self, Contract, ContractClauseRow}; use crate::error::{AppError, Result}; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; use crate::render; pub fn run(cmd: ContractCmd, ctx: Ctx) -> Result<()> { @@ -40,7 +40,10 @@ fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> { } let effective_iso = match args.effective { Some(s) => parse_date(&s)?, - None => chrono::Local::now().date_naive().format("%Y-%m-%d").to_string(), + None => chrono::Local::now() + .date_naive() + .format("%Y-%m-%d") + .to_string(), }; let end_iso = match args.end { Some(s) => Some(parse_date(&s)?), @@ -50,30 +53,40 @@ fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> { (Some(_), Some(_)) => { return Err(AppError::InvalidInput( "pass at most one of --term-months / --term-years".into(), - )) + )); } (Some(m), None) => Some(m), - (None, Some(y)) => Some(y * 12), + (None, Some(y)) => Some( + y.checked_mul(12) + .ok_or_else(|| AppError::InvalidInput("term years is too large".into()))?, + ), (None, None) => None, }; - if let Some(m) = term_months { - if m <= 0 { - return Err(AppError::InvalidInput(format!( - "invalid term length {m} — must be a positive number of months" - ))); - } + crate::legal::validate_dates(&effective_iso, end_iso.as_deref())?; + let selected_law = crate::legal::select_profile( + args.legal_profile.as_deref(), + args.us_state.as_deref(), + args.governing_law.as_deref(), + args.venue.as_deref(), + )?; + if let Some(m) = term_months + && m <= 0 + { + return Err(AppError::InvalidInput(format!( + "invalid term length {m} — must be a positive number of months" + ))); } if end_iso.is_some() && term_months.is_some() { return Err(AppError::InvalidInput( "--end and --term-months/--term-years are mutually exclusive".into(), )); } - if let Some(t) = &args.template { - if !crate::typst_assets::has_template(t)? { - return Err(AppError::InvalidInput(format!( - "template '{t}' not found. Run: contract template list" - ))); - } + if let Some(t) = &args.template + && !crate::typst_assets::has_template(t)? + { + return Err(AppError::InvalidInput(format!( + "template '{t}' not found. Run: contract template list" + ))); } // Parse fee @@ -123,7 +136,13 @@ fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> { if !args.deliverables.is_empty() { terms_obj.insert( "deliverables".into(), - Value::Array(args.deliverables.iter().cloned().map(Value::String).collect()), + Value::Array( + args.deliverables + .iter() + .cloned() + .map(Value::String) + .collect(), + ), ); } if let Some(ip) = args.ip_assignment.as_deref() { @@ -144,6 +163,17 @@ fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> { let (k, v) = parse_term_kv(spec)?; terms_obj.insert(k, Value::String(v)); } + if let Some(profile) = &args.legal_profile { + terms_obj.insert("legal_profile".into(), json!(profile)); + } + crate::legal::validate_terms(&mut terms_obj)?; + if args.kind == "ncnda" + && terms_obj.get("mutuality").and_then(Value::as_str) == Some("unilateral") + { + return Err(AppError::InvalidInput( + "the NCNDA pack is mutual; use nda for a unilateral disclosure agreement".into(), + )); + } let terms_json = Value::Object(terms_obj).to_string(); // All pure input validation is done — only now touch the database. @@ -163,16 +193,21 @@ fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> { })?; let issuer = db::issuer_by_slug(&conn, &issuer_slug)?; - let governing_law = args - .governing_law - .unwrap_or_else(|| issuer.jurisdiction.profile().country.to_string()); + let governing_law = selected_law.unwrap_or_else(|| { + let country = issuer.jurisdiction.profile().country; + if country == "United Kingdom" { + "England and Wales".into() + } else { + country.to_string() + } + }); + crate::legal::validate_law(&governing_law, args.venue.as_deref())?; let venue = args.venue; let title = args .title .unwrap_or_else(|| default_title(&args.kind, &issuer.name, &client.name)); - // Pick clause pack (default: "standard") let pack_slug = args.pack.clone().unwrap_or_else(|| "standard".to_string()); let pack = clauses::load_pack(&args.kind, &pack_slug)?; @@ -192,6 +227,11 @@ fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> { } } for slug in &args.exclude { + if !pack.clauses.contains_key(slug) { + return Err(AppError::InvalidInput(format!( + "unknown excluded clause {slug}" + ))); + } included.retain(|s| s != slug); } let clause_rows: Vec = included @@ -202,8 +242,8 @@ fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> { contract_id: 0, position: i as i64, slug: slug.clone(), - heading: None, - body: None, + heading: pack.clauses.get(slug).map(|d| d.heading.clone()), + body: pack.clauses.get(slug).map(|d| d.body.clone()), }) .collect(); @@ -253,7 +293,10 @@ fn cmd_new(args: ContractNewArgs, ctx: Ctx) -> Result<()> { print_success(ctx, &saved, |c| { println!( "created {} contract '{}' for {} ({} clauses)", - c.kind, c.number, c.title, clause_rows.len() + c.kind, + c.number, + c.title, + clause_rows.len() ); }); Ok(()) @@ -291,6 +334,13 @@ fn parse_fee(spec: &str) -> Result<(String, i64, String)> { "unknown fee type '{kind}' (expected fixed | hourly | daily | retainer)" ))); } + if let Some((_, cadence)) = parts[2].split_once('/') + && (kind != "retainer" || cadence != "month") + { + return Err(AppError::InvalidInput( + "only retainer fees support the /month suffix".into(), + )); + } // Strip a "/month"-style cadence suffix from the currency segment; the // cadence belongs in --fee-schedule, not the currency code. let currency = parts[2] @@ -350,6 +400,11 @@ fn parse_term_kv(spec: &str) -> Result<(String, String)> { )) })?; let k = k.trim(); + if k == "legal_profile" { + return Err(AppError::InvalidInput( + "use --legal-profile to select a jurisdiction profile".into(), + )); + } if k.is_empty() || !k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { return Err(AppError::InvalidInput(format!( "invalid --term key '{k}' — use snake_case letters/digits" @@ -409,7 +464,10 @@ fn cmd_show(number: &str, ctx: Ctx) -> Result<()> { let conn = db::open()?; let c = db::contract_get_or_404(&conn, number)?; let clauses = db::clauses_for(&conn, c.id)?; - let view = ContractView { contract: &c, clauses: clauses.clone() }; + let view = ContractView { + contract: &c, + clauses: clauses.clone(), + }; print_success(ctx, &view, |v| { println!("Contract: {}", v.contract.number); println!(" Kind: {}", v.contract.kind); @@ -427,11 +485,17 @@ fn cmd_show(number: &str, ctx: Ctx) -> Result<()> { println!( " Fee: {} {} {}", fee, - v.contract.fee_amount_minor.map(|m| (m as f64) / 100.0).unwrap_or(0.0), + v.contract + .fee_amount_minor + .map(|m| (m as f64) / 100.0) + .unwrap_or(0.0), v.contract.fee_currency.clone().unwrap_or_default() ); } - println!(" Pack: {} v{}", v.contract.clause_pack, v.contract.clause_pack_version); + println!( + " Pack: {} v{}", + v.contract.clause_pack, v.contract.clause_pack_version + ); println!(" Clauses ({}):", v.clauses.len()); for cl in &v.clauses { println!(" {:>2}. {}", cl.position + 1, cl.slug); @@ -450,8 +514,12 @@ fn cmd_edit(args: crate::cli::ContractEditArgs, ctx: Ctx) -> Result<()> { if let Some(slug) = args.client { c.client_id = db::client_by_slug(&conn, &slug)?.id; } - if let Some(v) = args.title { c.title = v; } - if let Some(v) = args.effective { c.effective_date = parse_date(&v)?; } + if let Some(v) = args.title { + c.title = v; + } + if let Some(v) = args.effective { + c.effective_date = parse_date(&v)?; + } if let Some(v) = args.end { c.end_date = Some(parse_date(&v)?); c.term_months = None; @@ -465,8 +533,18 @@ fn cmd_edit(args: crate::cli::ContractEditArgs, ctx: Ctx) -> Result<()> { c.term_months = Some(v); c.end_date = None; } - if let Some(v) = args.governing_law { c.governing_law = v; } - if let Some(v) = args.venue { c.venue = Some(v); } + let selected = crate::legal::select_profile( + args.legal_profile.as_deref(), + args.us_state.as_deref(), + args.governing_law.as_deref(), + args.venue.as_deref(), + )?; + if let Some(v) = selected { + c.governing_law = v; + } + if let Some(v) = args.venue { + c.venue = Some(v); + } if let Some(v) = args.fee { let (t, a, cur) = parse_fee(&v)?; c.fee_type = Some(t); @@ -480,18 +558,26 @@ fn cmd_edit(args: crate::cli::ContractEditArgs, ctx: Ctx) -> Result<()> { &["on-completion", "monthly", "on-milestone", "upon-invoice"], )?); } - if !args.terms.is_empty() { - let mut terms: serde_json::Map = - c.terms_json.parse::().ok() - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default(); + if !args.terms.is_empty() || args.legal_profile.is_some() { + let mut terms: serde_json::Map = c + .terms_json + .parse::() + .ok() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); for spec in &args.terms { let (k, v) = parse_term_kv(spec)?; terms.insert(k, Value::String(v)); } + if let Some(profile) = args.legal_profile { + terms.insert("legal_profile".into(), json!(profile)); + } + crate::legal::validate_terms(&mut terms)?; c.terms_json = Value::Object(terms).to_string(); } - if let Some(v) = args.notes { c.notes = Some(v); } + if let Some(v) = args.notes { + c.notes = Some(v); + } if let Some(v) = args.template { if !crate::typst_assets::has_template(&v)? { return Err(AppError::InvalidInput(format!( @@ -500,6 +586,28 @@ fn cmd_edit(args: crate::cli::ContractEditArgs, ctx: Ctx) -> Result<()> { } c.default_template = Some(v); } + let current_terms: Value = serde_json::from_str(&c.terms_json)?; + if c.kind == "ncnda" + && current_terms.get("mutuality").and_then(Value::as_str) == Some("unilateral") + { + return Err(AppError::InvalidInput( + "the NCNDA pack is mutual; use nda for a unilateral disclosure agreement".into(), + )); + } + if let Some(profile) = current_terms.get("legal_profile").and_then(Value::as_str) { + crate::legal::select_profile( + Some(profile), + if profile == "us" { + Some(c.governing_law.as_str()) + } else { + None + }, + Some(&c.governing_law), + c.venue.as_deref(), + )?; + } + crate::legal::validate_dates(&c.effective_date, c.end_date.as_deref())?; + crate::legal::validate_law(&c.governing_law, c.venue.as_deref())?; db::contract_update_draft(&conn, &c)?; let saved = db::contract_get(&conn, &number)?; print_success(ctx, &saved, |s| println!("updated draft '{}'", s.number)); @@ -520,15 +628,21 @@ fn cmd_render(args: ContractRenderArgs, ctx: Ctx) -> Result<()> { .find(|x| x.id == c.client_id) .ok_or_else(|| AppError::NotFound(format!("client #{}", c.client_id)))?; let clause_rows = db::clauses_for(&conn, c.id)?; - let pack = clauses::load_pack(&c.kind, &c.clause_pack)?; + let pack = clauses::load_pack_version(&c.kind, &c.clause_pack, &c.clause_pack_version)?; let template = args .template .or_else(|| c.default_template.clone()) + .or_else(|| { + crate::config::load() + .ok() + .map(|c| c.default_template) + .filter(|t| crate::typst_assets::has_template(t).unwrap_or(false)) + }) .unwrap_or_else(|| "helvetica-nera".to_string()); let out_path: PathBuf = match args.out { - Some(p) => PathBuf::from(p), + Some(p) => PathBuf::from(render::expand_tilde(&p)), None => { let dir = issuer .default_output_dir @@ -536,13 +650,31 @@ fn cmd_render(args: ContractRenderArgs, ctx: Ctx) -> Result<()> { .map(|s| PathBuf::from(render::expand_tilde(&s))) .unwrap_or_else(render::default_output_dir); std::fs::create_dir_all(&dir)?; - dir.join(format!("{}.pdf", c.number)) + let safe_number: String = c + .number + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + dir.join(format!("{safe_number}.pdf")) } }; let mut data = render::build_render_data( - &c, &issuer, &client, &clause_rows, &pack, args.draft, args.final_render, + &c, + &issuer, + &client, + &clause_rows, + &pack, + args.draft, + args.final_render, )?; + data.paper = args.paper; render::render_to_pdf(&template, &mut data, &issuer, &out_path)?; if args.open { @@ -597,7 +729,8 @@ fn transition_allowed(from: &str, to: &str) -> bool { fn cmd_mark(number: &str, status: &str, ctx: Ctx) -> Result<()> { let status = validate_choice("status", status, STATUSES)?; - let conn = db::open()?; + let mut connection = db::open()?; + let conn = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let current = db::contract_get_or_404(&conn, number)?; if !transition_allowed(¤t.status, &status) { return Err(AppError::InvalidInput(format!( @@ -605,16 +738,33 @@ fn cmd_mark(number: &str, status: &str, ctx: Ctx) -> Result<()> { number, current.status, status ))); } + if status == "draft" + && (current.signed_by_us_name.is_some() || current.signed_by_them_name.is_some()) + { + return Err(AppError::InvalidInput( + "a partially signed contract cannot be reopened; duplicate it as a new draft".into(), + )); + } db::contract_set_status(&conn, number, &status)?; let c = db::contract_get(&conn, number)?; + conn.commit()?; print_success(ctx, &c, |c| println!("'{}' → {}", c.number, c.status)); Ok(()) } fn cmd_sign(args: SignArgs, ctx: Ctx) -> Result<()> { let side = validate_choice("--side", &args.side, &["us", "them"])?; - let conn = db::open()?; + if args.name.trim().is_empty() { + return Err(AppError::InvalidInput("signer name cannot be blank".into())); + } + let mut connection = db::open()?; + let conn = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let existing = db::contract_get_or_404(&conn, &args.number)?; + if matches!(existing.status.as_str(), "expired" | "terminated") { + return Err(AppError::InvalidInput( + "cannot record signatures on an expired or terminated contract".into(), + )); + } let already = match side.as_str() { "us" => existing.signed_by_us_name.is_some(), _ => existing.signed_by_them_name.is_some(), @@ -627,7 +777,10 @@ fn cmd_sign(args: SignArgs, ctx: Ctx) -> Result<()> { } let date_iso = match args.date { Some(s) => parse_date(&s)?, - None => chrono::Local::now().date_naive().format("%Y-%m-%d").to_string(), + None => chrono::Local::now() + .date_naive() + .format("%Y-%m-%d") + .to_string(), }; let c = db::contract_record_signature( &conn, @@ -637,6 +790,7 @@ fn cmd_sign(args: SignArgs, ctx: Ctx) -> Result<()> { args.title.as_deref(), &date_iso, )?; + conn.commit()?; print_success(ctx, &c, |c| { println!( "recorded {} signature on '{}'. Status: {}.", @@ -665,20 +819,41 @@ fn cmd_duplicate( .find(|i| i.id == src.issuer_id) .ok_or_else(|| AppError::NotFound(format!("issuer #{}", src.issuer_id)))?, }; + let source_issuer = db::issuer_list(&conn)? + .into_iter() + .find(|i| i.id == src.issuer_id) + .ok_or_else(|| AppError::NotFound("source issuer".into()))?; + let source_client = db::client_list(&conn)? + .into_iter() + .find(|i| i.id == src.client_id) + .ok_or_else(|| AppError::NotFound("source client".into()))?; let client_id = match client { Some(slug) => db::client_by_slug(&conn, &slug)?.id, None => src.client_id, }; let year = chrono::Local::now().year(); let new_number = db::next_contract_number(&conn, &issuer, year, &src.kind)?; - let today = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string(); + let today = chrono::Local::now() + .date_naive() + .format("%Y-%m-%d") + .to_string(); + let new_client = db::client_list(&conn)? + .into_iter() + .find(|c| c.id == client_id) + .ok_or_else(|| AppError::NotFound("client".into()))?; + let copied_title = + if src.title == default_title(&src.kind, &source_issuer.name, &source_client.name) { + default_title(&src.kind, &issuer.name, &new_client.name) + } else { + src.title.clone() + }; let new_contract = Contract { id: 0, number: new_number.clone(), kind: src.kind.clone(), issuer_id: issuer.id, client_id, - title: src.title.clone(), + title: copied_title, effective_date: today, // A copied absolute end date would predate the new effective date; // keep relative terms, drop absolute ones for the user to re-set. diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index c44482a..e17a5ca 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -2,7 +2,7 @@ use std::process::Command; use crate::config; use crate::error::{AppError, Result}; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; use crate::typst_assets; #[derive(serde::Serialize)] diff --git a/src/commands/issuers.rs b/src/commands/issuers.rs index aa68b02..f447477 100644 --- a/src/commands/issuers.rs +++ b/src/commands/issuers.rs @@ -1,7 +1,7 @@ use crate::cli::IssuerCmd; use crate::db::{self, Issuer}; use crate::error::{AppError, Result}; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; use crate::tax::Jurisdiction; use super::split_multiline_arg; @@ -70,21 +70,40 @@ pub fn run(cmd: IssuerCmd, ctx: Ctx) -> Result<()> { output_dir, } => { let mut existing = db::issuer_by_slug(&conn, &slug)?; - if let Some(v) = name { existing.name = v; } - if let Some(v) = legal_name { existing.legal_name = Some(v); } + if let Some(v) = name { + existing.name = v; + } + if let Some(v) = legal_name { + existing.legal_name = Some(v); + } if let Some(v) = jurisdiction { - existing.jurisdiction = Jurisdiction::from_str(&v).ok_or_else(|| { - AppError::InvalidInput(format!("unknown jurisdiction '{v}'")) - })?; + existing.jurisdiction = Jurisdiction::from_str(&v) + .ok_or_else(|| AppError::InvalidInput(format!("unknown jurisdiction '{v}'")))?; + } + if let Some(v) = tax_id { + existing.tax_id = Some(v); + } + if let Some(v) = company_no { + existing.company_no = Some(v); + } + if let Some(v) = address { + existing.address = split_multiline_arg(&v); + } + if let Some(v) = email { + existing.email = Some(v); + } + if let Some(v) = phone { + existing.phone = Some(v); + } + if logo_clear { + existing.logo_path = None; + } + if let Some(v) = logo { + existing.logo_path = Some(v); + } + if let Some(v) = output_dir { + existing.default_output_dir = Some(v); } - if let Some(v) = tax_id { existing.tax_id = Some(v); } - if let Some(v) = company_no { existing.company_no = Some(v); } - if let Some(v) = address { existing.address = split_multiline_arg(&v); } - if let Some(v) = email { existing.email = Some(v); } - if let Some(v) = phone { existing.phone = Some(v); } - if logo_clear { existing.logo_path = None; } - if let Some(v) = logo { existing.logo_path = Some(v); } - if let Some(v) = output_dir { existing.default_output_dir = Some(v); } db::issuer_update(&conn, &existing)?; print_success(ctx, &existing, |i| println!("updated issuer '{}'", i.slug)); Ok(()) @@ -93,7 +112,9 @@ pub fn run(cmd: IssuerCmd, ctx: Ctx) -> Result<()> { let list = db::issuer_list(&conn)?; print_success(ctx, &list, |rows| { if rows.is_empty() { - println!("(no issuers — add one with: contract issuer add --name X --address ...)"); + println!( + "(no issuers — add one with: contract issuer add --name X --address ...)" + ); } else { for i in rows { println!( diff --git a/src/commands/kinds.rs b/src/commands/kinds.rs index 5969e24..480c180 100644 --- a/src/commands/kinds.rs +++ b/src/commands/kinds.rs @@ -1,7 +1,7 @@ use crate::cli::KindsCmd; use crate::error::{AppError, Result}; use crate::kinds; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; #[derive(serde::Serialize)] struct KindInfo { diff --git a/src/commands/pack.rs b/src/commands/pack.rs index fa54b45..557e35d 100644 --- a/src/commands/pack.rs +++ b/src/commands/pack.rs @@ -1,7 +1,7 @@ -use crate::cli::PackCmd; use crate::clauses; +use crate::cli::PackCmd; use crate::error::Result; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; pub fn run(cmd: PackCmd, ctx: Ctx) -> Result<()> { match cmd { diff --git a/src/commands/skill.rs b/src/commands/skill.rs index 4877619..53c0f79 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -1,6 +1,6 @@ use crate::cli::SkillCmd; use crate::error::Result; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; // The skill is a signpost, not a manual: the binary carries all workflow // knowledge in agent-info and --help, so the skill body stays tiny and the diff --git a/src/commands/template.rs b/src/commands/template.rs index 6b01fc4..98b97e0 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -1,11 +1,11 @@ use chrono::Utc; use std::path::PathBuf; -use crate::cli::TemplateCmd; use crate::clauses; +use crate::cli::TemplateCmd; use crate::db::{Client, Contract, ContractClauseRow, Issuer}; use crate::error::Result; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; use crate::render; use crate::tax::Jurisdiction; use crate::typst_assets; @@ -74,10 +74,17 @@ pub fn run(cmd: TemplateCmd, ctx: Ctx) -> Result<()> { }); Ok(()) } - TemplateCmd::Preview { name, kind, out } => { + TemplateCmd::Preview { + name, + kind, + out, + pack: pack_slug, + paper, + } => { let issuer = sample_issuer(); let client = sample_client(); - let contract = sample_contract(&kind); + let mut contract = sample_contract(&kind); + contract.clause_pack = pack_slug; let pack = clauses::load_pack(&kind, &contract.clause_pack)?; let clause_rows: Vec = pack .pack @@ -93,10 +100,19 @@ pub fn run(cmd: TemplateCmd, ctx: Ctx) -> Result<()> { body: None, }) .collect(); - let mut data = - render::build_render_data(&contract, &issuer, &client, &clause_rows, &pack, false, true)?; - let out_path = - PathBuf::from(out.unwrap_or_else(|| format!("preview-{name}-{kind}.pdf"))); + let mut data = render::build_render_data( + &contract, + &issuer, + &client, + &clause_rows, + &pack, + false, + true, + )?; + data.paper = paper; + let out_path = PathBuf::from(render::expand_tilde( + &out.unwrap_or_else(|| format!("preview-{name}-{kind}.pdf")), + )); render::render_to_pdf(&name, &mut data, &issuer, &out_path)?; print_success(ctx, &out_path.display().to_string(), |p| { println!("preview → {p}"); @@ -117,10 +133,7 @@ fn sample_issuer() -> Issuer { tax_id: None, company_no: Some("202312345A".into()), tagline: None, - address: vec![ - "1 Marina Bay".into(), - "Singapore 018989".into(), - ], + address: vec!["1 Marina Bay".into(), "Singapore 018989".into()], email: Some("hello@acme.example".into()), phone: None, bank_details: None, @@ -163,7 +176,7 @@ fn sample_contract(kind: &str) -> Contract { "nda" => serde_json::json!({ "mutuality": "mutual", "disclosing_side": "both", - "purpose": "a potential collaboration on a longevity research project", + "purpose": "a potential collaboration on a new software product", "confidentiality_years": 3, }), "ncnda" => serde_json::json!({ @@ -175,7 +188,7 @@ fn sample_contract(kind: &str) -> Contract { "commission_text": "a commission as separately agreed in writing between the parties", }), "loan" => serde_json::json!({ - "purpose": "a personal loan between the parties", + "purpose": "a commercial loan between the parties", "principal_text": "S$10,000 (ten thousand Singapore dollars)", "interest_text": "interest-free", "repayment_date": "1 December 2026", @@ -184,7 +197,7 @@ fn sample_contract(kind: &str) -> Contract { "purpose": "the design and delivery of a customer-facing dashboard for the Client's flagship product", "deliverables": [ "Discovery interviews and a one-page strategy memo", - "Three rounds of high-fidelity Figma designs", + "Interface designs within the agreed revision allowance", "Production-ready front-end implementation in React", "One handover session with the Client's engineering team", ], @@ -198,6 +211,7 @@ fn sample_contract(kind: &str) -> Contract { "termination_notice_days": 30, }), "sow" => serde_json::json!({ + "msa_reference": "MSA-EXAMPLE-2026-001, dated 1 September 2026", "purpose": "Implementation of the customer dashboard described in the kick-off memo dated 2026-04-01.", "deliverables": [ "Functional prototype by week 4", @@ -215,10 +229,25 @@ fn sample_contract(kind: &str) -> Contract { _ => serde_json::json!({}), }; let (fee_type, fee_minor, fee_cur, fee_sched) = match kind { - "consulting" => (Some("fixed".into()), Some(84_000_00i64), Some("SGD".into()), Some("on-completion".into())), + "consulting" => ( + Some("fixed".into()), + Some(840_000_i64), + Some("SGD".into()), + Some("on-completion".into()), + ), "msa" => (None, None, None, None), - "sow" => (Some("fixed".into()), Some(34_000_00i64), Some("SGD".into()), Some("on-milestone".into())), - "service" => (Some("retainer".into()), Some(5_000_00i64), Some("SGD".into()), Some("monthly".into())), + "sow" => ( + Some("fixed".into()), + Some(340_000_i64), + Some("SGD".into()), + Some("on-milestone".into()), + ), + "service" => ( + Some("retainer".into()), + Some(500_000_i64), + Some("SGD".into()), + Some("monthly".into()), + ), _ => (None, None, None, None), }; let title = match kind { @@ -254,7 +283,7 @@ fn sample_contract(kind: &str) -> Contract { fee_schedule: fee_sched, terms_json: terms.to_string(), clause_pack: "standard".into(), - clause_pack_version: "1.0".into(), + clause_pack_version: "2.0".into(), default_template: None, signed_by_us_name: None, signed_by_us_title: None, diff --git a/src/commands/update.rs b/src/commands/update.rs index c02c0d1..97b8ca2 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -5,7 +5,7 @@ use std::process::Command; use crate::error::{AppError, Result}; -use crate::output::{print_success, Ctx}; +use crate::output::{Ctx, print_success}; const CRATES_IO_URL: &str = "https://crates.io/api/v1/crates/contract-cli"; const BREW_FORMULA: &str = "paperfoot/tap/contract"; @@ -41,24 +41,27 @@ pub fn run(ctx: Ctx, check: bool) -> Result<()> { ) }; - if let Ok(cfg) = crate::config::load() { - if !cfg.self_update { - let report = UpdateReport { - current_version: current, - latest_version: None, - status: "disabled", - install_source, - update_mode: "disabled", - upgrade_command, - release_url: RELEASE_URL, - requires_skill_reinstall: false, - note: Some("self_update = false in config; upgrade via the package manager".into()), - }; - print_success(ctx, &report, |r| { - println!("updates disabled by config. Upgrade manually: {}", r.upgrade_command) - }); - return Ok(()); - } + if let Ok(cfg) = crate::config::load() + && !cfg.self_update + { + let report = UpdateReport { + current_version: current, + latest_version: None, + status: "disabled", + install_source, + update_mode: "disabled", + upgrade_command, + release_url: RELEASE_URL, + requires_skill_reinstall: false, + note: Some("self_update = false in config; upgrade via the package manager".into()), + }; + print_success(ctx, &report, |r| { + println!( + "updates disabled by config. Upgrade manually: {}", + r.upgrade_command + ) + }); + return Ok(()); } let latest = match fetch_latest_version() { @@ -89,7 +92,11 @@ pub fn run(ctx: Ctx, check: bool) -> Result<()> { }; let is_newer = version_newer_than(&latest, ¤t); - let status = if is_newer { "update_available" } else { "up_to_date" }; + let status = if is_newer { + "update_available" + } else { + "up_to_date" + }; if check || !is_newer { let report = UpdateReport { @@ -120,10 +127,7 @@ pub fn run(ctx: Ctx, check: bool) -> Result<()> { let ok = if install_source == "homebrew" { run_cmd("brew", &["upgrade", BREW_FORMULA])? } else { - run_cmd( - "cargo", - &["install", "--locked", "--force", "contract-cli"], - )? + run_cmd("cargo", &["install", "--locked", "--force", "contract-cli"])? }; if !ok { return Err(AppError::Transient( diff --git a/src/db.rs b/src/db.rs index 29ee2a8..789b104 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,7 +5,7 @@ // contracts (V7), contract_clauses (V7), number_series (shared). // ═══════════════════════════════════════════════════════════════════════════ -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -16,11 +16,15 @@ pub use finance_core::entity::Issuer; pub fn open() -> Result { let paths = finance_core::paths::Paths::resolve()?; - Ok(finance_core::db::open(&paths)?) + let mut conn = finance_core::db::open(&paths)?; + upgrade_contract_kinds(&mut conn)?; + Ok(conn) } pub fn open_at(path: &Path) -> Result { - Ok(finance_core::db::open_at(path)?) + let mut conn = finance_core::db::open_at(path)?; + upgrade_contract_kinds(&mut conn)?; + Ok(conn) } // ─── Issuers (shared with invoice-cli) ──────────────────────────────────── @@ -33,6 +37,7 @@ fn text_to_addr(s: &str) -> Vec { } pub fn issuer_create(conn: &Connection, issuer: &Issuer) -> Result { + crate::typst_assets::validate_name(&issuer.slug)?; conn.execute( "INSERT INTO issuers (slug, name, legal_name, jurisdiction, tax_registered, tax_id, company_no, tagline, address, email, phone, @@ -405,7 +410,11 @@ fn row_to_contract(row: &rusqlite::Row) -> rusqlite::Result { }) } -pub fn contract_create(conn: &mut Connection, c: &Contract, clauses: &[ContractClauseRow]) -> Result { +pub fn contract_create( + conn: &mut Connection, + c: &Contract, + clauses: &[ContractClauseRow], +) -> Result { let tx = conn.transaction()?; // Explicit RFC3339 stamps — the column DEFAULT CURRENT_TIMESTAMP emits a // different format ("YYYY-MM-DD HH:MM:SS") than the update paths write. @@ -508,20 +517,20 @@ pub fn contract_update_draft(conn: &Connection, c: &Contract) -> Result<()> { ) .optional()?; let status = status.ok_or_else(|| AppError::NotFound(format!("contract '{}'", c.number)))?; - if status != "draft" { + if status != "draft" || c.signed_by_us_name.is_some() || c.signed_by_them_name.is_some() { return Err(AppError::InvalidInput(format!( "contract '{}' is {status}, not draft — sent/signed contracts are immutable.", c.number ))); } let now = chrono::Utc::now().to_rfc3339(); - conn.execute( + let changed = conn.execute( "UPDATE contracts SET client_id = ?1, title = ?2, effective_date = ?3, end_date = ?4, term_months = ?5, governing_law = ?6, venue = ?7, notes = ?8, fee_type = ?9, fee_amount_minor = ?10, fee_currency = ?11, fee_schedule = ?12, terms_json = ?13, default_template = ?14, updated_at = ?15 - WHERE number = ?16", + WHERE number = ?16 AND status = 'draft' AND signed_by_us_name IS NULL AND signed_by_them_name IS NULL", params![ c.client_id, c.title, @@ -541,6 +550,11 @@ pub fn contract_update_draft(conn: &Connection, c: &Contract) -> Result<()> { c.number, ], )?; + if changed != 1 { + return Err(AppError::InvalidInput( + "contract changed concurrently or is no longer an unsigned draft".into(), + )); + } Ok(()) } @@ -555,7 +569,7 @@ pub fn contract_set_status(conn: &Connection, number: &str, status: &str) -> Res let now = chrono::Utc::now().to_rfc3339(); let affected = match status { "sent" => conn.execute( - "UPDATE contracts SET status = ?1, sent_at = COALESCE(sent_at, ?2), updated_at = ?2 WHERE number = ?3", + "UPDATE contracts SET status = ?1, sent_at = ?2, updated_at = ?2 WHERE number = ?3", params![status, now, number], )?, "signed" | "active" => conn.execute( @@ -658,7 +672,7 @@ pub fn clauses_for(conn: &Connection, contract_id: i64) -> Result Result { let c = contract_get_or_404(conn, number)?; - if c.status != "draft" { + if c.status != "draft" || c.signed_by_us_name.is_some() || c.signed_by_them_name.is_some() { return Err(AppError::InvalidInput(format!( "contract '{number}' is {} — clauses can only be edited on draft.", c.status @@ -675,8 +689,8 @@ pub fn clause_add( body: Option<&str>, position: Option, ) -> Result { - let c = require_mutable(conn, number)?; - let tx = conn.transaction()?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let c = require_mutable(&tx, number)?; // disallow duplicates let exists: Option = tx .query_row( @@ -721,8 +735,8 @@ pub fn clause_add( } pub fn clause_remove(conn: &mut Connection, number: &str, slug: &str) -> Result<()> { - let c = require_mutable(conn, number)?; - let tx = conn.transaction()?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let c = require_mutable(&tx, number)?; let pos: Option = tx .query_row( "SELECT position FROM contract_clauses WHERE contract_id = ?1 AND slug = ?2", @@ -754,7 +768,7 @@ pub fn clause_edit( let c = require_mutable(conn, number)?; let affected = conn.execute( "UPDATE contract_clauses SET heading = COALESCE(?1, heading), body = COALESCE(?2, body) - WHERE contract_id = ?3 AND slug = ?4", + WHERE contract_id = ?3 AND slug = ?4 AND EXISTS (SELECT 1 FROM contracts WHERE id = ?3 AND status = 'draft' AND signed_by_us_name IS NULL AND signed_by_them_name IS NULL)", params![heading, body, c.id, slug], )?; if affected == 0 { @@ -766,8 +780,8 @@ pub fn clause_edit( } pub fn clause_move(conn: &mut Connection, number: &str, slug: &str, new_pos: i64) -> Result<()> { - let c = require_mutable(conn, number)?; - let tx = conn.transaction()?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let c = require_mutable(&tx, number)?; let cur_pos: i64 = tx .query_row( "SELECT position FROM contract_clauses WHERE contract_id = ?1 AND slug = ?2", @@ -812,8 +826,8 @@ pub fn clauses_reset( number: &str, fresh: &[ContractClauseRow], ) -> Result<()> { - let c = require_mutable(conn, number)?; - let tx = conn.transaction()?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let c = require_mutable(&tx, number)?; tx.execute( "DELETE FROM contract_clauses WHERE contract_id = ?1", params![c.id], @@ -849,13 +863,86 @@ pub fn next_contract_number( params![issuer.id, year, series_kind], |r| r.get(0), )?; - let prefix = match kind { - "consulting" => "CTR", - "nda" => "NDA", - "msa" => "MSA", - "sow" => "SOW", - "service" => "SVC", - _ => "DOC", - }; + let prefix = crate::kinds::prefix_for(kind); Ok(format!("{prefix}-{}-{}-{:04}", issuer.slug, year, seq)) } + +/// Compatibility migration for finance-core 0.4's five-kind CHECK. Rebuild the +/// table using SQLite's documented procedure; preserve data, indexes, triggers, +/// foreign keys and the AUTOINCREMENT high-water mark. No shared migration edits. +fn upgrade_contract_kinds(conn: &mut Connection) -> Result<()> { + let sql: String = conn.query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='contracts'", + [], + |r| r.get(0), + )?; + let old = "'consulting','nda','msa','sow','service'"; + if !sql.contains(old) || (sql.contains("'ncnda'") && sql.contains("'loan'")) { + return Ok(()); + } + conn.pragma_update(None, "foreign_keys", false)?; + let result = (|| -> Result<()> { + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + // Re-read after acquiring the write lock: another process may have migrated. + let sql: String = tx.query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='contracts'", + [], + |r| r.get(0), + )?; + if !sql.contains(old) || (sql.contains("'ncnda'") && sql.contains("'loan'")) { + tx.commit()?; + return Ok(()); + } + let objects: Vec = { + let mut stmt = tx.prepare("SELECT sql FROM sqlite_master WHERE tbl_name='contracts' AND type IN ('index','trigger') AND sql IS NOT NULL")?; + stmt.query_map([], |r| r.get(0))? + .collect::>()? + }; + let seq: i64 = tx + .query_row( + "SELECT seq FROM sqlite_sequence WHERE name='contracts'", + [], + |r| r.get(0), + ) + .optional()? + .unwrap_or(0); + let create = sql + .replacen( + "CREATE TABLE contracts", + "CREATE TABLE contracts_upgrade", + 1, + ) + .replace( + old, + "'consulting','nda','msa','sow','service','ncnda','loan'", + ); + if !create.starts_with("CREATE TABLE contracts_upgrade") { + return Err(AppError::Other( + "unrecognised contracts schema; migration refused".into(), + )); + } + tx.execute_batch(&create)?; + tx.execute_batch("INSERT INTO contracts_upgrade SELECT * FROM contracts; DROP TABLE contracts; ALTER TABLE contracts_upgrade RENAME TO contracts;")?; + for object in objects { + tx.execute_batch(&object)?; + } + tx.execute( + "UPDATE sqlite_sequence SET seq=MAX(seq,?1) WHERE name='contracts'", + [seq], + )?; + let broken: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_foreign_key_check)", + [], + |r| r.get(0), + )?; + if broken { + return Err(AppError::Other( + "contract schema upgrade failed foreign-key validation".into(), + )); + } + tx.commit()?; + Ok(()) + })(); + conn.pragma_update(None, "foreign_keys", true)?; + result +} diff --git a/src/kinds.rs b/src/kinds.rs index 5ecd4ed..ab926ea 100644 --- a/src/kinds.rs +++ b/src/kinds.rs @@ -20,8 +20,17 @@ pub const KINDS: &[KindSpec] = &[ roles: ("Disclosing Party", "Receiving Party"), description: "Confidentiality agreement (mutual or one-way) — protects information exchanged while exploring a deal.", tags: &[ - "confidential", "confidentiality", "secret", "nondisclosure", "non-disclosure", - "disclosure", "privacy", "protect", "information", "evaluate", "exploring", + "confidential", + "confidentiality", + "secret", + "nondisclosure", + "non-disclosure", + "disclosure", + "privacy", + "protect", + "information", + "evaluate", + "exploring", ], }, KindSpec { @@ -30,9 +39,22 @@ pub const KINDS: &[KindSpec] = &[ roles: ("Party", "Party"), description: "Non-circumvention + non-disclosure — stops a counterparty going around you to deal directly with contacts you introduce, and protects the information shared.", tags: &[ - "circumvent", "circumvention", "non-circumvention", "go-around", "bypass", - "introducer", "introduction", "intermediary", "broker", "middleman", - "commission", "protect-contacts", "steal", "poach", "direct-deal", "cut-out", + "circumvent", + "circumvention", + "non-circumvention", + "go-around", + "bypass", + "introducer", + "introduction", + "intermediary", + "broker", + "middleman", + "commission", + "protect-contacts", + "steal", + "poach", + "direct-deal", + "cut-out", ], }, KindSpec { @@ -41,26 +63,44 @@ pub const KINDS: &[KindSpec] = &[ roles: ("Consultant", "Client"), description: "One engagement with defined deliverables and a fee — a self-contained services contract.", tags: &[ - "freelance", "project", "deliverables", "engagement", "gig", "advisory", - "consultant", "services", "scope", "fee", + "freelance", + "project", + "deliverables", + "engagement", + "gig", + "advisory", + "consultant", + "services", + "scope", + "fee", ], }, KindSpec { kind: "msa", prefix: "MSA", - roles: ("Provider", "Customer"), + roles: ("Provider", "Client"), description: "Master services agreement — umbrella terms with no fee or scope; SOWs hang off it per piece of work.", tags: &[ - "master", "framework", "umbrella", "long-term", "terms", "relationship", + "master", + "framework", + "umbrella", + "long-term", + "terms", + "relationship", ], }, KindSpec { kind: "sow", prefix: "SOW", - roles: ("Provider", "Customer"), + roles: ("Provider", "Client"), description: "Statement of work under an MSA — scope, deliverables, milestones, and price for one project.", tags: &[ - "scope", "milestones", "work-order", "statement", "under-msa", "project", + "scope", + "milestones", + "work-order", + "statement", + "under-msa", + "project", ], }, KindSpec { @@ -69,8 +109,15 @@ pub const KINDS: &[KindSpec] = &[ roles: ("Provider", "Customer"), description: "Ongoing or recurring services — retainers, hosting, support, monthly reviews.", tags: &[ - "retainer", "ongoing", "recurring", "monthly", "hosting", "support", - "subscription", "maintenance", "managed", + "retainer", + "ongoing", + "recurring", + "monthly", + "hosting", + "support", + "subscription", + "maintenance", + "managed", ], }, KindSpec { @@ -79,8 +126,19 @@ pub const KINDS: &[KindSpec] = &[ roles: ("Lender", "Borrower"), description: "Loan agreement between two parties — principal, interest (or interest-free), repayment date, default, and optional security.", tags: &[ - "loan", "lend", "lending", "borrow", "money", "principal", "interest", - "repayment", "repay", "credit", "advance", "bridge", "facility", + "loan", + "lend", + "lending", + "borrow", + "money", + "principal", + "interest", + "repayment", + "repay", + "credit", + "advance", + "bridge", + "facility", ], }, ]; @@ -114,7 +172,10 @@ pub fn score(query: &str, description: &str, tags: &[&str]) -> f64 { // exact tag hit is strongest; tag prefix and description hits count less if tags.iter().any(|tag| tag == t) { hits += 3.0; - } else if tags.iter().any(|tag| tag.starts_with(*t) || t.starts_with(*tag)) { + } else if tags + .iter() + .any(|tag| tag.starts_with(*t) || t.starts_with(*tag)) + { hits += 1.5; } else if desc.contains(*t) { hits += 1.0; diff --git a/src/legal.rs b/src/legal.rs new file mode 100644 index 0000000..6c8c580 --- /dev/null +++ b/src/legal.rs @@ -0,0 +1,233 @@ +//! Explicit legal-profile selection and validation shared by creation, editing and rendering. +use crate::error::{AppError, Result}; +use serde_json::{Map, Value}; + +fn invalid(message: impl Into) -> AppError { + AppError::InvalidInput(message.into()) +} + +pub fn validate_dates(effective: &str, end: Option<&str>) -> Result<()> { + let parse = |s: &str| { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map_err(|_| invalid(format!("invalid date {s}; expected YYYY-MM-DD"))) + }; + let start = parse(effective)?; + if let Some(end) = end + && parse(end)? < start + { + return Err(invalid("end date must not precede the effective date")); + } + Ok(()) +} + +pub fn validate_law(law: &str, venue: Option<&str>) -> Result<()> { + let l = law.trim().to_lowercase(); + if l.is_empty() + || matches!( + l.as_str(), + "uk" | "united kingdom" + | "us" + | "usa" + | "united states" + | "global" + | "international" + | "eu" + | "european union" + ) + { + return Err(invalid( + "choose a specific governing law, such as England and Wales, Scotland, Delaware or Singapore; global is a profile, not a legal system", + )); + } + if venue.is_some_and(|v| v.trim().is_empty()) { + return Err(invalid("court venue cannot be blank")); + } + Ok(()) +} + +pub fn select_profile( + profile: Option<&str>, + state: Option<&str>, + law: Option<&str>, + venue: Option<&str>, +) -> Result> { + if state.is_some() && profile != Some("us") { + return Err(invalid("--us-state requires --legal-profile us")); + } + let selected = match profile { + Some("uk") => { + let law = law.unwrap_or("England and Wales"); + if !["england and wales", "scotland", "northern ireland"] + .contains(&law.trim().to_lowercase().as_str()) + { + return Err(invalid( + "UK profile requires England and Wales, Scotland or Northern Ireland", + )); + } + Some(law.to_string()) + } + Some("singapore") => { + if law.is_some_and(|l| !l.trim().eq_ignore_ascii_case("singapore")) { + return Err(invalid( + "Singapore profile requires Singapore governing law", + )); + } + Some("Singapore".into()) + } + Some("us") => { + let state = state + .ok_or_else(|| invalid("US profile requires --us-state, e.g. Delaware"))? + .trim(); + let states = [ + "Alabama", + "Alaska", + "Arizona", + "Arkansas", + "California", + "Colorado", + "Connecticut", + "Delaware", + "Florida", + "Georgia", + "Hawaii", + "Idaho", + "Illinois", + "Indiana", + "Iowa", + "Kansas", + "Kentucky", + "Louisiana", + "Maine", + "Maryland", + "Massachusetts", + "Michigan", + "Minnesota", + "Mississippi", + "Missouri", + "Montana", + "Nebraska", + "Nevada", + "New Hampshire", + "New Jersey", + "New Mexico", + "New York", + "North Carolina", + "North Dakota", + "Ohio", + "Oklahoma", + "Oregon", + "Pennsylvania", + "Rhode Island", + "South Carolina", + "South Dakota", + "Tennessee", + "Texas", + "Utah", + "Vermont", + "Virginia", + "Washington", + "West Virginia", + "Wisconsin", + "Wyoming", + "District of Columbia", + ]; + let state = states + .iter() + .find(|s| s.eq_ignore_ascii_case(state)) + .ok_or_else(|| { + invalid("use the full name of a US state or District of Columbia") + })?; + if law.is_some_and(|l| !l.trim().eq_ignore_ascii_case(state)) { + return Err(invalid("--governing-law must match --us-state")); + } + Some(state.to_string()) + } + Some("global") => { + let law = + law.ok_or_else(|| invalid("global profile requires --governing-law and --venue"))?; + if venue.is_none() { + return Err(invalid("global profile requires an explicit --venue")); + } + Some(law.to_string()) + } + Some(other) => return Err(invalid(format!("unknown legal profile {other}"))), + None => law.map(str::to_string), + }; + if let Some(law) = &selected { + validate_law(law, venue)?; + } + Ok(selected) +} + +pub fn validate_terms(terms: &mut Map) -> Result<()> { + for (key, min, max) in [ + ("confidentiality_years", 1, 100), + ("termination_notice_days", 0, 3650), + ("non_circumvention_months", 1, 120), + ("acceptance_days", 1, 365), + ("revision_rounds", 0, 100), + ] { + if let Some(value) = terms.get(key) { + let n = value + .as_i64() + .or_else(|| value.as_str().and_then(|s| s.parse::().ok())) + .ok_or_else(|| invalid(format!("{key} must be a whole number")))?; + if n < min || n > max { + return Err(invalid(format!("{key} must be between {min} and {max}"))); + } + terms.insert(key.into(), Value::from(n)); + } + } + for (key, choices) in [ + ("mutuality", &["mutual", "unilateral"][..]), + ("disclosing_side", &["us", "them", "both"][..]), + ( + "ip_assignment", + &["client", "consultant", "provider", "shared"][..], + ), + ("legal_profile", &["global", "uk", "us", "singapore"][..]), + ] { + if let Some(value) = terms.get(key) + && !value.as_str().is_some_and(|s| choices.contains(&s)) + { + return Err(invalid(format!( + "invalid {key}; expected {}", + choices.join(" | ") + ))); + } + } + let unilateral = terms.get("mutuality").and_then(Value::as_str) == Some("unilateral"); + if let Some(side) = terms.get("disclosing_side").and_then(Value::as_str) + && ((unilateral && side == "both") || (!unilateral && side != "both")) + { + return Err(invalid( + "mutual NDAs require disclosing_side=both; unilateral NDAs require us or them", + )); + } + if let Some(v) = terms.get("deliverables") + && !v.as_array().is_some_and(|a| { + a.iter() + .all(|v| v.as_str().is_some_and(|s| !s.trim().is_empty())) + }) + { + return Err(invalid( + "deliverables must be a list of non-empty strings; use --deliverable", + )); + } + Ok(()) +} + +pub fn venue_phrase(law: &str, venue: Option<&str>, profile: Option<&str>) -> String { + if let Some(venue) = venue { + return venue.trim().to_string(); + } + if profile == Some("us") { + return format!( + "the state courts of {law} and, where federal subject-matter jurisdiction exists, the federal courts located there" + ); + } + format!("the courts of {}", law.trim()) +} + +/// DTSA immunity notice. Only inserted for an explicitly selected US profile. +pub const US_IMMUNITY: &str = "Under 18 U.S.C. § 1833(b), an individual is immune from liability under federal or state trade-secret law for disclosing a trade secret in confidence, directly or indirectly, to a government official or attorney solely to report or investigate a suspected violation of law, or in a complaint or other document filed under seal in a proceeding. An individual bringing a retaliation claim for reporting suspected illegality may disclose the trade secret to their attorney and use it in that proceeding if documents containing it are filed under seal and it is not otherwise disclosed except by court order. These protections include qualifying contractors and consultants. Nothing in this agreement restricts those rights."; diff --git a/src/lib.rs b/src/lib.rs index 3d398f1..b4265a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,13 +2,14 @@ pub use finance_core::money; pub use finance_core::tax; -pub mod cli; pub mod clauses; +pub mod cli; pub mod commands; pub mod config; pub mod db; pub mod error; pub mod kinds; +pub mod legal; pub mod output; pub mod render; pub mod typst_assets; diff --git a/src/render.rs b/src/render.rs index 25f119c..4d17a6d 100644 --- a/src/render.rs +++ b/src/render.rs @@ -24,6 +24,7 @@ use crate::typst_assets; #[serde(rename_all = "kebab-case")] pub struct ContractRenderData { pub kind: String, + pub paper: String, /// Formal document type — the title of the page (e.g. "Consulting /// Services Agreement", "Mutual Non-Disclosure Agreement"). This is /// what real contracts put at the top, not the kind tag from the CLI. @@ -54,7 +55,7 @@ pub struct ContractRenderData { pub our_party: PartyData, pub their_party: PartyData, /// Parties as numbered prose lines, the traditional UK contract format: - /// "**BORIS DJORDJEVIC** of 199 Gloucester Terrace, London W2 6LD, + /// "**ALEX MORGAN** of 10 Example Street, London, /// United Kingdom (the \"Consultant\")" /// Templates render them as a numbered list "(1) … ; and (2) ….". pub parties_prose: Vec, @@ -130,18 +131,26 @@ fn kind_label(kind: &str, terms: &serde_json::Value) -> String { fn capitalize(s: &str) -> String { let mut chars = s.chars(); - chars.next().map(|c| c.to_uppercase().collect::() + chars.as_str()) + chars + .next() + .map(|c| c.to_uppercase().collect::() + chars.as_str()) .unwrap_or_default() } fn party_role_labels(kind: &str, terms: &serde_json::Value) -> (String, String) { match kind { "nda" => { - let mutuality = terms.get("mutuality").and_then(|v| v.as_str()).unwrap_or("mutual"); + let mutuality = terms + .get("mutuality") + .and_then(|v| v.as_str()) + .unwrap_or("mutual"); if mutuality == "mutual" { ("Party A".into(), "Party B".into()) } else { - let disclosing = terms.get("disclosing_side").and_then(|v| v.as_str()).unwrap_or("us"); + let disclosing = terms + .get("disclosing_side") + .and_then(|v| v.as_str()) + .unwrap_or("us"); if disclosing == "us" { ("Disclosing Party".into(), "Receiving Party".into()) } else { @@ -162,7 +171,11 @@ fn jurisdiction_phrase(law: &str) -> String { let lower = l.to_lowercase(); if lower.contains("singapore") { "the courts of Singapore".into() - } else if lower.contains("england") || lower.contains("wales") || lower.contains("united kingdom") || lower == "uk" { + } else if lower.contains("england") + || lower.contains("wales") + || lower.contains("united kingdom") + || lower == "uk" + { "the courts of England and Wales".into() } else if lower.contains("delaware") { "the state and federal courts located in Delaware".into() @@ -209,7 +222,11 @@ fn term_short(c: &Contract) -> String { } else if let Some(m) = c.term_months { if m % 12 == 0 { let y = m / 12; - if y == 1 { "1 year".into() } else { format!("{y} years") } + if y == 1 { + "1 year".into() + } else { + format!("{y} years") + } } else if m == 1 { "1 month".into() } else { @@ -240,8 +257,11 @@ fn fee_short(c: &Contract) -> Option { }) } -fn ip_assignment_text(terms: &serde_json::Value) -> String { - let mode = terms.get("ip_assignment").and_then(|v| v.as_str()).unwrap_or("client"); +fn legacy_ip_assignment_text(terms: &serde_json::Value) -> String { + let mode = terms + .get("ip_assignment") + .and_then(|v| v.as_str()) + .unwrap_or("client"); match mode { "client" => "All deliverables produced specifically for the Client under this engagement (the “Deliverables”) belong to the Client. The Consultant assigns to the Client, on payment of the relevant fees, all right, title, and interest in the Deliverables.".into(), "consultant" | "provider" => "The Consultant retains ownership of all deliverables. The Client receives a non-exclusive, perpetual, worldwide, royalty-free licence to use them for its internal business purposes.".into(), @@ -250,6 +270,21 @@ fn ip_assignment_text(terms: &serde_json::Value) -> String { } } +fn ip_assignment_text(terms: &serde_json::Value, our: &str, their: &str) -> String { + let mode = terms + .get("ip_assignment") + .and_then(|v| v.as_str()) + .unwrap_or("client"); + let disposition = match mode { + "client" => format!("Upon payment of the fees attributable to the final deliverables expressly identified in the agreed scope (the ‘Deliverables’), the {our} hereby assigns to the {their}, by way of present assignment of existing and future rights to the extent permitted by law, all intellectual property rights it owns in those Deliverables, excluding Background IP and third-party materials. The {our} shall obtain necessary rights from its personnel and execute reasonable further documents to give effect to this assignment. Until payment, the {their} may use the Deliverables solely for review and acceptance."), + "shared" => "The parties shall agree a separate signed schedule specifying ownership shares, exploitation, licensing, enforcement and accounting before creating jointly owned Deliverables. Pending that agreement, each party retains ownership of its own contributions; neither party may commercialise the other's contribution without written permission.".to_string(), + _ => format!("The {our} retains ownership of the Deliverables. On payment of the applicable fees, the {their} receives a non-exclusive, perpetual, worldwide, royalty-free licence to use and adapt the Deliverables for its internal business purposes, and to allow service providers to do so on its behalf. Resale, sublicensing for third-party use and public distribution require an express written licence."), + }; + format!( + "{disposition}\n\nEach party retains its pre-existing or independently developed tools, libraries, methods and know-how (‘Background IP’). On payment, the {our} grants the {their} a non-exclusive, perpetual, worldwide, royalty-free licence to use, reproduce and adapt its Background IP incorporated into Deliverables, and to permit its customers and service providers to use it, solely as needed for the agreed use of those Deliverables. Third-party materials remain subject to disclosed third-party licences. Moral rights are waived only to the extent lawfully permitted and expressly agreed in writing by the relevant rights holder; otherwise necessary consents shall be obtained." + ) +} + fn fee_text(c: &Contract) -> Option { let kind = c.fee_type.as_deref()?; let amt = c.fee_amount_minor?; @@ -312,8 +347,21 @@ fn esc_markup(s: &str) -> String { } if matches!( c, - '\\' | '#' | '*' | '_' | '`' | '$' | '<' | '>' | '@' | '[' | ']' | '~' | '/' | '-' - | '=' | '+' + '\\' | '#' + | '*' + | '_' + | '`' + | '$' + | '<' + | '>' + | '@' + | '[' + | ']' + | '~' + | '/' + | '-' + | '=' + | '+' ) { out.push('\\'); } @@ -328,7 +376,10 @@ fn esc_markup(s: &str) -> String { /// individuals just have name + address. The role label is the contract-side /// label (Consultant / Client / Provider / Party A / etc). fn party_intro_prose(p: &PartyData) -> String { - let legal = p.legal_name.clone().unwrap_or_else(|| p.display_name.clone()); + let legal = p + .legal_name + .clone() + .unwrap_or_else(|| p.display_name.clone()); let name_upper = esc_markup(&legal.to_uppercase()); let addr = esc_markup(&p.address.join(", ")); let looks_like_company = looks_like_company_name(&legal); @@ -344,6 +395,9 @@ fn party_intro_prose(p: &PartyData) -> String { // Jurisdiction missing but company number known. qualifier.push_str(&format!(", company no. {}", esc_markup(co))); } + if let Some(email) = &p.email { + qualifier.push_str(&format!(", email: {}", esc_markup(email))); + } // Use Typst's single-asterisk bold syntax so the name reads as bold. format!( "*{name_upper}* of {addr}{qualifier} (the \"{role}\")", @@ -354,9 +408,24 @@ fn party_intro_prose(p: &PartyData) -> String { fn looks_like_company_name(s: &str) -> bool { let lower = s.to_lowercase(); [ - " ltd", " limited", " inc", " incorporated", " corp", " corporation", - " pte", " llc", " llp", " plc", " gmbh", " ag", " sa", " s.a.", - " sas", " bv", " nv", " pty", + " ltd", + " limited", + " inc", + " incorporated", + " corp", + " corporation", + " pte", + " llc", + " llp", + " plc", + " gmbh", + " ag", + " sa", + " s.a.", + " sas", + " bv", + " nv", + " pty", ] .iter() .any(|s| lower.contains(s)) @@ -413,8 +482,14 @@ fn vars_from( their_role: &str, ) -> BTreeMap { let mut v = BTreeMap::new(); - let our_legal = issuer.legal_name.clone().unwrap_or_else(|| issuer.name.clone()); - let their_legal = client.legal_name.clone().unwrap_or_else(|| client.name.clone()); + let our_legal = issuer + .legal_name + .clone() + .unwrap_or_else(|| issuer.name.clone()); + let their_legal = client + .legal_name + .clone() + .unwrap_or_else(|| client.name.clone()); v.insert("our_name".into(), issuer.name.clone()); v.insert("our_legal_name".into(), our_legal); v.insert("our_role".into(), our_role.to_string()); @@ -426,18 +501,29 @@ fn vars_from( v.insert("effective_date".into(), fmt_date(&contract.effective_date)); v.insert( "end_date".into(), - contract.end_date.as_deref().map(fmt_date).unwrap_or_default(), + contract + .end_date + .as_deref() + .map(fmt_date) + .unwrap_or_default(), ); v.insert("term_text".into(), term_text(contract)); v.insert("governing_law".into(), contract.governing_law.clone()); v.insert( "jurisdiction_phrase".into(), - jurisdiction_phrase(&contract.governing_law), - ); - v.insert( - "venue".into(), - contract.venue.clone().unwrap_or_default(), + crate::legal::venue_phrase( + &contract.governing_law, + contract.venue.as_deref(), + terms.get("legal_profile").and_then(|v| v.as_str()), + ), ); + v.insert("venue".into(), contract.venue.clone().unwrap_or_default()); + if contract.clause_pack_version.starts_with("1.") && contract.venue.is_none() { + v.insert( + "jurisdiction_phrase".into(), + jurisdiction_phrase(&contract.governing_law), + ); + } // NDA specifics v.insert( "purpose".into(), @@ -459,7 +545,10 @@ fn vars_from( "confidentiality_years".into(), terms .get("confidentiality_years") - .and_then(|x| x.as_i64()) + .and_then(|x| { + x.as_i64() + .or_else(|| x.as_str().and_then(|s| s.parse().ok())) + }) .map(|n| n.to_string()) .unwrap_or_else(|| "3".into()), ); @@ -467,23 +556,63 @@ fn vars_from( "termination_notice_days".into(), terms .get("termination_notice_days") - .and_then(|x| x.as_i64()) + .and_then(|x| { + x.as_i64() + .or_else(|| x.as_str().and_then(|s| s.parse().ok())) + }) .or_else(|| { contract .terms_json .parse::() .ok() - .and_then(|t| t.get("termination_notice_days").and_then(|x| x.as_i64())) + .and_then(|t| { + t.get("termination_notice_days").and_then(|x| { + x.as_i64() + .or_else(|| x.as_str().and_then(|s| s.parse().ok())) + }) + }) }) .map(|n| n.to_string()) .unwrap_or_else(|| "30".into()), ); // Consulting / SOW + v.insert("deliverables_block".into(), deliverables_block(terms)); v.insert( - "deliverables_block".into(), - deliverables_block(terms), + "ip_assignment_text".into(), + if contract.clause_pack_version.starts_with("1.") { + legacy_ip_assignment_text(terms) + } else { + ip_assignment_text(terms, our_role, their_role) + }, ); - v.insert("ip_assignment_text".into(), ip_assignment_text(terms)); + let unilateral = terms.get("mutuality").and_then(|v| v.as_str()) == Some("unilateral"); + let definition = if unilateral { + let (discloser, recipient) = + if terms.get("disclosing_side").and_then(|v| v.as_str()) == Some("them") { + (&client.name, &issuer.name) + } else { + (&issuer.name, &client.name) + }; + format!( + "For this agreement, {discloser} is the disclosing party and {recipient} is the receiving party. The confidentiality and restricted-use obligations protect information disclosed by the disclosing party to the receiving party; they do not create reciprocal confidentiality duties." + ) + } else { + "Each party is the disclosing party for information it discloses and the receiving party for information it receives. The confidentiality and restricted-use obligations apply reciprocally.".into() + }; + v.insert("nda_definition".into(), definition); + for (key, fallback) in [("acceptance_days", "10"), ("revision_rounds", "2")] { + v.insert( + key.into(), + terms + .get(key) + .map(|v| { + v.as_str() + .map(str::to_owned) + .unwrap_or_else(|| v.to_string()) + }) + .unwrap_or_else(|| fallback.into()), + ); + } v.insert( "fee_text".into(), fee_text(contract).unwrap_or_else(|| "as separately agreed in writing".into()), @@ -533,7 +662,36 @@ pub fn build_render_data( overrides.insert(r.slug.clone(), (r.heading.clone(), r.body.clone())); } } - let resolved = clauses::resolve(pack, &included, &overrides, &vars)?; + let mut resolved = clauses::resolve(pack, &included, &overrides, &vars)?; + if !contract.clause_pack_version.starts_with("1.") + && terms.get("legal_profile").and_then(|v| v.as_str()) == Some("us") + { + resolved.push(clauses::ResolvedClause { + position: resolved.len() as i64, + slug: "us_trade_secret_notice".into(), + heading: "Protected trade-secret disclosures".into(), + body: crate::legal::US_IMMUNITY.into(), + }); + } + if force_final + || (!force_draft + && matches!( + contract.status.as_str(), + "signed" | "active" | "expired" | "terminated" + )) + { + let unresolved: Vec<_> = resolved + .iter() + .filter(|c| c.body.contains("{{") || c.heading.contains("{{")) + .map(|c| c.slug.as_str()) + .collect(); + if !unresolved.is_empty() { + return Err(AppError::InvalidInput(format!( + "unresolved terms in clauses: {}. Supply the missing --term key=value before final rendering", + unresolved.join(", ") + ))); + } + } let render_clauses: Vec = resolved .into_iter() .enumerate() @@ -584,7 +742,10 @@ pub fn build_render_data( let our_party = party_display(issuer, &our_role); let their_party = client_display(client, &their_role); - let parties_prose = vec![party_intro_prose(&our_party), party_intro_prose(&their_party)]; + let parties_prose = vec![ + party_intro_prose(&our_party), + party_intro_prose(&their_party), + ]; // Compute subtitle: suppress when contract.title is just the auto-default. let auto = auto_default_title(&contract.kind, &issuer.name, &client.name); let subtitle = if contract.title.trim() == auto.trim() { @@ -595,6 +756,7 @@ pub fn build_render_data( Ok(ContractRenderData { kind: contract.kind.clone(), + paper: "a4".into(), kind_label: kind_label(&contract.kind, &terms), number: contract.number.clone(), subtitle, @@ -603,7 +765,11 @@ pub fn build_render_data( term_text: term_text(contract), term_short: term_short(contract), governing_law: contract.governing_law.clone(), - jurisdiction_phrase: jurisdiction_phrase(&contract.governing_law), + jurisdiction_phrase: crate::legal::venue_phrase( + &contract.governing_law, + contract.venue.as_deref(), + terms.get("legal_profile").and_then(|v| v.as_str()), + ), venue: contract.venue.clone(), status: contract.status.clone(), draft_watermark: draft, @@ -615,7 +781,7 @@ pub fn build_render_data( clauses: render_clauses, signature, logo: None, // populated by render_to_pdf if issuer has a logo - internal_notes: contract.notes.clone(), + internal_notes: None, }) } @@ -625,18 +791,22 @@ pub fn render_to_pdf( issuer: &Issuer, out_path: &Path, ) -> Result<()> { - typst_assets::ensure_extracted()?; if !typst_assets::has_template(template)? { return Err(AppError::InvalidInput(format!( "template '{template}' not found. Run: contract template list" ))); } + typst_assets::ensure_extracted()?; let tmp = tempfile::Builder::new() .prefix("contract-cli-render-") .tempdir()?; let root = tmp.path(); - copy_dir_contents(&typst_assets::project_root()?, root)?; + let assets = typst_assets::project_root()?; + // Fonts are reusable immutable assets; Typst's font search may read them + // directly. Avoid copying the entire font library for every document. + copy_dir_contents(&assets.join("shared"), &root.join("shared"))?; + copy_dir_contents(&assets.join("templates"), &root.join("templates"))?; // Copy logo (if any) into shared/ and set the json path. data.logo = stage_logo(root, issuer)?; @@ -649,21 +819,38 @@ pub fn render_to_pdf( cmd.arg("compile").arg("--root").arg(root); // Embedded OFL fonts (typst/fonts/**) travel with the assets; point typst // at them so templates render identically on machines without the faces. - let fonts_dir = root.join("fonts"); + let fonts_dir = assets.join("fonts"); if fonts_dir.is_dir() { cmd.arg("--font-path").arg(&fonts_dir); } - let status = cmd + let parent = out_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + let staged = tempfile::Builder::new() + .prefix(".contract-") + .suffix(".pdf") + .tempfile_in(parent)?; + let output = cmd .arg(&template_path) - .arg(out_path) - .status() + .arg(staged.path()) + .output() .map_err(|e| AppError::Render(format!("typst binary not found: {e}")))?; - if !status.success() { + if !output.status.success() { return Err(AppError::Render(format!( - "typst compile exited with {}", - status.code().unwrap_or(-1) + "typst compile failed: {}", + String::from_utf8_lossy(&output.stderr) ))); } + let bytes = std::fs::read(staged.path())?; + if !bytes.starts_with(b"%PDF-") { + return Err(AppError::Render("compiler did not produce a PDF".into())); + } + staged.as_file().sync_all()?; + staged + .persist(out_path) + .map_err(|e| AppError::Io(e.error))?; Ok(()) } @@ -673,6 +860,11 @@ fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> { let entry = entry?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); + if entry.file_type()?.is_symlink() { + return Err(AppError::InvalidInput( + "symlink in template assets is not supported".into(), + )); + } if src_path.is_dir() { copy_dir_contents(&src_path, &dst_path)?; } else { @@ -688,7 +880,7 @@ fn stage_logo(root: &Path, issuer: &Issuer) -> Result> { }; let src_expanded = expand_tilde(src_raw); let src = Path::new(&src_expanded); - if !src.exists() { + if !src.is_file() { eprintln!( "warning: logo '{}' not found for issuer '{}' — rendering without", src.display(), @@ -701,7 +893,10 @@ fn stage_logo(root: &Path, issuer: &Issuer) -> Result> { .and_then(|e| e.to_str()) .unwrap_or("png") .to_lowercase(); - let rel = format!("shared/logo-{}.{ext}", issuer.slug); + if !["png", "jpg", "jpeg", "svg", "gif", "webp"].contains(&ext.as_str()) { + return Err(AppError::InvalidInput("unsupported logo format".into())); + } + let rel = format!("shared/logo.{ext}"); let dst = root.join(&rel); if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent)?; @@ -711,15 +906,17 @@ fn stage_logo(root: &Path, issuer: &Issuer) -> Result> { } pub fn expand_tilde(s: &str) -> String { - if let Some(rest) = s.strip_prefix("~/") { - if let Ok(home) = std::env::var("HOME") { - return format!("{home}/{rest}"); - } + if let Some(rest) = s.strip_prefix("~/") + && let Ok(home) = std::env::var("HOME") + { + return format!("{home}/{rest}"); } s.to_string() } pub fn default_output_dir() -> std::path::PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - std::path::PathBuf::from(home).join("Documents").join("Contracts") + std::path::PathBuf::from(home) + .join("Documents") + .join("Contracts") } diff --git a/src/typst_assets.rs b/src/typst_assets.rs index b5a2af4..4c7cd7e 100644 --- a/src/typst_assets.rs +++ b/src/typst_assets.rs @@ -4,7 +4,7 @@ use rust_embed::RustEmbed; use crate::config; -use crate::error::Result; +use crate::error::{AppError, Result}; #[derive(RustEmbed)] #[folder = "typst/"] @@ -40,6 +40,7 @@ pub fn template_dir() -> Result { } pub fn template_path(name: &str) -> Result { + validate_name(name)?; Ok(template_dir()?.join(format!("{name}.typ"))) } @@ -51,10 +52,10 @@ pub fn list_templates() -> Result> { for entry in std::fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("typ") { - if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { - names.push(name.to_string()); - } + if path.extension().and_then(|s| s.to_str()) == Some("typ") + && let Some(name) = path.file_stem().and_then(|s| s.to_str()) + { + names.push(name.to_string()); } } } @@ -62,9 +63,24 @@ pub fn list_templates() -> Result> { Ok(names) } +pub fn validate_name(name: &str) -> Result<()> { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(AppError::InvalidInput( + "template names may contain only letters, digits, hyphens and underscores".into(), + )); + } + Ok(()) +} pub fn has_template(name: &str) -> Result { - ensure_extracted()?; - Ok(template_path(name)?.exists()) + validate_name(name)?; + if Assets::get(&format!("templates/{name}.typ")).is_some() { + return Ok(true); + } + Ok(template_path(name)?.is_file()) } /// Per-template metadata, parsed from the `//!` doc-comment header at the top @@ -117,10 +133,7 @@ pub fn template_meta(name: &str) -> Result { } pub fn list_template_meta() -> Result> { - list_templates()? - .iter() - .map(|n| template_meta(n)) - .collect() + list_templates()?.iter().map(|n| template_meta(n)).collect() } fn split_list(s: &str) -> Vec { diff --git a/tests/contracts.rs b/tests/contracts.rs index a4df018..a5624e1 100644 --- a/tests/contracts.rs +++ b/tests/contracts.rs @@ -99,7 +99,10 @@ fn agent_info_has_required_schema_keys() { } assert_eq!(m["auto_json_when_piped"], true); for code in ["0", "1", "2", "3", "4"] { - assert!(m["exit_codes"].get(code).is_some(), "exit code {code} documented"); + assert!( + m["exit_codes"].get(code).is_some(), + "exit code {code} documented" + ); } assert!(m["global_flags"].get("--json").is_some()); assert!(m["global_flags"].get("--quiet").is_some()); @@ -113,7 +116,9 @@ fn agent_info_commands_are_canonical_objects_and_routable() { let commands = m["commands"].as_object().expect("commands object"); assert!(!commands.is_empty()); for (key, value) in commands { - let obj = value.as_object().unwrap_or_else(|| panic!("`{key}` is an object")); + let obj = value + .as_object() + .unwrap_or_else(|| panic!("`{key}` is an object")); assert!(obj.contains_key("description"), "`{key}` has description"); assert!(obj.contains_key("args"), "`{key}` has args"); assert!(obj.contains_key("options"), "`{key}` has options"); @@ -144,7 +149,11 @@ fn template_list_returns_metadata() { #[test] fn kinds_find_resolves_non_circumvention_language() { let out = contract() - .args(["kinds", "find", "stop them going around me to steal my contact"]) + .args([ + "kinds", + "find", + "stop them going around me to steal my contact", + ]) .assert() .success(); let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); @@ -185,9 +194,22 @@ fn new_rejects_unknown_kind() { #[test] fn new_rejects_bad_fee_specs() { - for fee in ["fixed:nan:SGD", "fixed:0:SGD", "fixed:-5:SGD", "retainer:5000:SGD/month/x:y"] { + for fee in [ + "fixed:nan:SGD", + "fixed:0:SGD", + "fixed:-5:SGD", + "retainer:5000:SGD/month/x:y", + ] { contract() - .args(["new", "--kind", "consulting", "--client", "nobody", "--fee", fee]) + .args([ + "new", + "--kind", + "consulting", + "--client", + "nobody", + "--fee", + fee, + ]) .assert() .code(3); } @@ -196,11 +218,27 @@ fn new_rejects_bad_fee_specs() { #[test] fn new_rejects_bad_enum_values() { contract() - .args(["new", "--kind", "nda", "--client", "nobody", "--mutuality", "Mutual-ish"]) + .args([ + "new", + "--kind", + "nda", + "--client", + "nobody", + "--mutuality", + "Mutual-ish", + ]) .assert() .code(3); contract() - .args(["new", "--kind", "nda", "--client", "nobody", "--term-months", "0"]) + .args([ + "new", + "--kind", + "nda", + "--client", + "nobody", + "--term-months", + "0", + ]) .assert() .code(3); } diff --git a/tests/draft_edits.rs b/tests/draft_edits.rs new file mode 100644 index 0000000..594c060 --- /dev/null +++ b/tests/draft_edits.rs @@ -0,0 +1,249 @@ +use std::process::{Command, Output}; + +use serde_json::Value; +use tempfile::TempDir; + +struct TestHome { + root: TempDir, +} + +impl TestHome { + fn new() -> Self { + Self { + root: tempfile::Builder::new() + .prefix("contract-cli-draft-edits-") + .tempdir() + .expect("create isolated test home"), + } + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_contract")); + command + .env_clear() + .env("HOME", self.root.path()) + .env("XDG_CONFIG_HOME", self.root.path().join(".config")) + .env("XDG_DATA_HOME", self.root.path().join(".local/share")) + .env("XDG_CACHE_HOME", self.root.path().join(".cache")) + .arg("--json"); + command + } + + fn run(&self, args: &[&str]) -> Output { + self.command() + .args(args) + .output() + .unwrap_or_else(|error| panic!("run contract {args:?}: {error}")) + } + + fn success(&self, args: &[&str]) -> Value { + let output = self.run(args); + assert!( + output.status.success(), + "contract {args:?} failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let envelope: Value = + serde_json::from_slice(&output.stdout).expect("success output is JSON"); + assert_eq!(envelope["status"], "success", "command: {args:?}"); + envelope["data"].clone() + } + + fn rejects(&self, args: &[&str]) -> Value { + let output = self.run(args); + assert_eq!( + output.status.code(), + Some(3), + "contract {args:?} should reject invalid input\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let envelope: Value = serde_json::from_slice(&output.stderr).expect("error output is JSON"); + assert_eq!(envelope["status"], "error", "command: {args:?}"); + envelope["error"].clone() + } + + fn add_fictional_parties(&self) { + self.success(&[ + "issuer", + "add", + "fictionalholdings", + "--name", + "Fictional Holdings Pte Ltd", + "--legal-name", + "Fictional Holdings Pte. Ltd.", + "--jurisdiction", + "sg", + "--address", + "1 Fictional Street\nSingapore 000001", + ]); + for (slug, name, address) in [ + ( + "fictionalclient", + "Fictional Client Ltd", + "2 Fictional Street\nSingapore 000002", + ), + ( + "newfictionalclient", + "New Fictional Client Ltd", + "3 Fictional Street\nSingapore 000003", + ), + ] { + self.success(&[ + "clients", + "add", + slug, + "--name", + name, + "--legal-name", + name, + "--jurisdiction", + "Singapore", + "--address", + address, + ]); + } + } + + fn new_singapore_contract(&self, kind: &str) -> Value { + self.success(&[ + "new", + "--kind", + kind, + "--as", + "fictionalholdings", + "--client", + "fictionalclient", + "--purpose", + "Evaluate a fictional commercial relationship", + "--effective", + "2026-09-10", + "--legal-profile", + "singapore", + ]) + } +} + +fn terms(contract: &Value) -> Value { + serde_json::from_str( + contract["terms_json"] + .as_str() + .expect("contract terms_json string"), + ) + .expect("valid contract terms_json") +} + +#[test] +fn singapore_profile_rejects_conflicting_draft_law_without_mutation() { + let home = TestHome::new(); + home.add_fictional_parties(); + let created = home.new_singapore_contract("nda"); + let number = created["number"].as_str().expect("contract number"); + + home.rejects(&["edit", number, "--governing-law", "Delaware"]); + + let unchanged = home.success(&["show", number]); + assert_eq!(unchanged["governing_law"], "Singapore"); + assert_eq!(terms(&unchanged)["legal_profile"], "singapore"); +} + +#[test] +fn ncnda_draft_rejects_unilateral_terms_without_mutation() { + let home = TestHome::new(); + home.add_fictional_parties(); + let created = home.new_singapore_contract("ncnda"); + let number = created["number"].as_str().expect("contract number"); + + home.rejects(&[ + "edit", + number, + "--term", + "mutuality=unilateral", + "--term", + "disclosing_side=us", + ]); + + let unchanged = home.success(&["show", number]); + let unchanged_terms = terms(&unchanged); + assert_eq!(unchanged_terms["mutuality"], "mutual"); + assert_eq!(unchanged_terms["disclosing_side"], "both"); +} + +#[test] +fn legal_profile_is_reserved_from_free_form_draft_terms() { + let home = TestHome::new(); + home.add_fictional_parties(); + let created = home.new_singapore_contract("nda"); + let number = created["number"].as_str().expect("contract number"); + + let error = home.rejects(&["edit", number, "--term", "legal_profile=us"]); + assert!( + error.to_string().contains("use --legal-profile"), + "reserved term rejection points to the supported flag: {error}" + ); + + let unchanged = home.success(&["show", number]); + assert_eq!(terms(&unchanged)["legal_profile"], "singapore"); +} + +#[test] +fn config_rejects_unknown_template_and_preserves_valid_folio_setting() { + let home = TestHome::new(); + + home.success(&["config", "set", "default_template", "folio"]); + home.rejects(&[ + "config", + "set", + "default_template", + "bogus-fictional-template", + ]); + + let config = home.success(&["config", "show"]); + assert_eq!(config["default_template"], "folio"); +} + +#[test] +fn duplicate_for_new_client_recomputes_auto_title_and_clears_signatures() { + let home = TestHome::new(); + home.add_fictional_parties(); + let source = home.new_singapore_contract("nda"); + let source_number = source["number"].as_str().expect("source contract number"); + + home.success(&[ + "sign", + source_number, + "--side", + "us", + "--name", + "Fictional Signer One", + ]); + home.success(&[ + "sign", + source_number, + "--side", + "them", + "--name", + "Fictional Signer Two", + ]); + + let duplicated = home.success(&["duplicate", source_number, "--client", "newfictionalclient"]); + assert_eq!( + duplicated["title"], + "NDA — Fictional Holdings Pte Ltd & New Fictional Client Ltd" + ); + assert_eq!(duplicated["governing_law"], "Singapore"); + assert_eq!(terms(&duplicated)["legal_profile"], "singapore"); + assert_eq!(duplicated["status"], "draft"); + for field in [ + "signed_at", + "signed_by_us_name", + "signed_by_us_title", + "signed_by_us_at", + "signed_by_them_name", + "signed_by_them_title", + "signed_by_them_at", + ] { + assert!(duplicated[field].is_null(), "duplicate retains {field}"); + } +} diff --git a/tests/reliability.rs b/tests/reliability.rs new file mode 100644 index 0000000..6e3d9a2 --- /dev/null +++ b/tests/reliability.rs @@ -0,0 +1,289 @@ +use std::process::{Command, Output}; + +use serde_json::Value; +use tempfile::TempDir; + +struct TestHome { + root: TempDir, +} + +impl TestHome { + fn new() -> Self { + Self { + root: tempfile::Builder::new() + .prefix("contract-cli-reliability-") + .tempdir() + .expect("create isolated test home"), + } + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_contract")); + command + .env("HOME", self.root.path()) + .env("XDG_CONFIG_HOME", self.root.path().join(".config")) + .env("XDG_DATA_HOME", self.root.path().join(".local/share")) + .env("XDG_CACHE_HOME", self.root.path().join(".cache")) + .arg("--json"); + command + } + + fn run(&self, args: &[&str]) -> Output { + self.command() + .args(args) + .output() + .unwrap_or_else(|error| panic!("run contract {args:?}: {error}")) + } + + fn success(&self, args: &[&str]) -> Value { + let output = self.run(args); + assert!( + output.status.success(), + "contract {args:?} failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let envelope: Value = + serde_json::from_slice(&output.stdout).expect("success output is JSON"); + assert_eq!(envelope["status"], "success", "command: {args:?}"); + envelope["data"].clone() + } + + fn rejects(&self, args: &[&str]) { + let output = self.run(args); + assert_eq!( + output.status.code(), + Some(3), + "contract {args:?} should reject invalid input\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let envelope: Value = serde_json::from_slice(&output.stderr).expect("error output is JSON"); + assert_eq!(envelope["status"], "error", "command: {args:?}"); + } + + fn add_parties(&self) { + self.success(&[ + "issuer", + "add", + "acme", + "--name", + "Acme Example Ltd", + "--legal-name", + "Acme Example Limited", + "--jurisdiction", + "uk", + "--address", + "1 Example Street\nLondon\nEX1 1AA", + ]); + self.success(&[ + "clients", + "add", + "meridian", + "--name", + "Meridian Example Ltd", + "--legal-name", + "Meridian Example Limited", + "--jurisdiction", + "England and Wales", + "--address", + "2 Example Avenue\nLondon\nEX2 2BB", + ]); + } + + fn new_nda(&self, extra: &[&str]) -> Value { + let mut args = vec![ + "new", + "--kind", + "nda", + "--as", + "acme", + "--client", + "meridian", + "--purpose", + "Evaluate a software partnership", + ]; + args.extend_from_slice(extra); + self.success(&args) + } + + fn reject_new_nda(&self, extra: &[&str]) { + let mut args = vec![ + "new", + "--kind", + "nda", + "--as", + "acme", + "--client", + "meridian", + "--purpose", + "Evaluate a software partnership", + ]; + args.extend_from_slice(extra); + self.rejects(&args); + } +} + +#[test] +fn all_seven_contract_kinds_can_be_created() { + let home = TestHome::new(); + home.add_parties(); + + for kind in [ + "nda", + "ncnda", + "consulting", + "msa", + "sow", + "service", + "loan", + ] { + let contract = home.success(&[ + "new", + "--kind", + kind, + "--as", + "acme", + "--client", + "meridian", + "--purpose", + "Evaluate a software partnership", + "--legal-profile", + "uk", + ]); + assert_eq!(contract["kind"], kind); + assert!(contract["number"].as_str().is_some()); + assert_eq!(contract["status"], "draft"); + } +} + +#[test] +fn new_rejects_reversed_dates_and_term_year_overflow() { + let home = TestHome::new(); + home.add_parties(); + + home.reject_new_nda(&["--effective", "2026-06-01", "--end", "2026-05-31"]); + + let too_many_years = i64::MAX.to_string(); + home.reject_new_nda(&["--term-years", &too_many_years]); +} + +#[test] +fn new_rejects_invalid_confidentiality_and_unilateral_disclosure_terms() { + let home = TestHome::new(); + home.add_parties(); + + home.reject_new_nda(&["--term", "confidentiality_years=-1"]); + home.reject_new_nda(&["--mutuality", "unilateral", "--disclosing-side", "both"]); +} + +#[test] +fn new_rejects_template_path_traversal() { + let home = TestHome::new(); + home.add_parties(); + + home.reject_new_nda(&["--template", "../shared/contract"]); +} + +#[test] +fn legal_profiles_require_their_mandatory_arguments() { + let home = TestHome::new(); + home.add_parties(); + + home.reject_new_nda(&["--legal-profile", "us"]); + home.reject_new_nda(&[ + "--legal-profile", + "global", + "--venue", + "Courts of New South Wales, Australia", + ]); + home.reject_new_nda(&[ + "--legal-profile", + "global", + "--governing-law", + "New South Wales, Australia", + ]); +} + +#[test] +fn legal_profiles_select_expected_law_and_accept_global_venue() { + let home = TestHome::new(); + home.add_parties(); + + let uk = home.new_nda(&["--legal-profile", "uk"]); + assert_eq!(uk["governing_law"], "England and Wales"); + + let singapore = home.new_nda(&["--legal-profile", "singapore"]); + assert_eq!(singapore["governing_law"], "Singapore"); + + let us = home.new_nda(&["--legal-profile", "us", "--us-state", "Delaware"]); + assert_eq!(us["governing_law"], "Delaware"); + + let global = home.new_nda(&[ + "--legal-profile", + "global", + "--governing-law", + "New South Wales, Australia", + "--venue", + "Courts of New South Wales, Australia", + ]); + assert_eq!(global["governing_law"], "New South Wales, Australia"); + assert_eq!(global["venue"], "Courts of New South Wales, Australia"); +} + +#[test] +fn sign_rejects_blank_signer_name() { + let home = TestHome::new(); + home.add_parties(); + let contract = home.new_nda(&[]); + let number = contract["number"].as_str().expect("contract number"); + + home.rejects(&["sign", number, "--side", "us", "--name", " "]); +} + +#[test] +fn first_signature_locks_contract_edits_clause_edits_and_recall() { + let home = TestHome::new(); + home.add_parties(); + let contract = home.new_nda(&[]); + let number = contract["number"].as_str().expect("contract number"); + + home.success(&["sign", number, "--side", "us", "--name", "Alex Example"]); + home.rejects(&["edit", number, "--title", "Changed after signing"]); + home.rejects(&[ + "contracts", + "clauses", + "edit", + number, + "purpose", + "--body", + "Changed after signing.", + ]); + home.success(&["mark", number, "sent"]); + home.rejects(&["mark", number, "draft"]); +} + +#[test] +fn terminated_contract_cannot_be_signed_even_with_force() { + let home = TestHome::new(); + home.add_parties(); + let contract = home.new_nda(&[]); + let number = contract["number"].as_str().expect("contract number"); + + home.success(&["sign", number, "--side", "us", "--name", "Alex Example"]); + home.success(&["sign", number, "--side", "them", "--name", "Morgan Example"]); + home.success(&["mark", number, "terminated"]); + home.rejects(&[ + "sign", + number, + "--side", + "us", + "--name", + "Replacement Signer", + "--force", + ]); + + let shown = home.success(&["show", number]); + assert_eq!(shown["status"], "terminated"); + assert_eq!(shown["signed_by_us_name"], "Alex Example"); +} diff --git a/tests/storage.rs b/tests/storage.rs new file mode 100644 index 0000000..0c2a9c5 --- /dev/null +++ b/tests/storage.rs @@ -0,0 +1,272 @@ +use contract_cli::{clauses, db}; +use rusqlite::{Connection, params}; + +fn object_exists(conn: &Connection, object_type: &str, name: &str) -> bool { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = ?1 AND name = ?2)", + params![object_type, name], + |row| row.get(0), + ) + .expect("inspect sqlite schema") +} + +fn assert_database_health(conn: &Connection) { + let foreign_keys: i64 = conn + .pragma_query_value(None, "foreign_keys", |row| row.get(0)) + .expect("read foreign_keys pragma"); + assert_eq!(foreign_keys, 1, "foreign key enforcement remains enabled"); + + let broken_references: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .expect("run foreign_key_check"); + assert_eq!( + broken_references, 0, + "migration leaves no broken references" + ); +} + +fn insert_minimal_contract( + conn: &Connection, + number: &str, + kind: &str, + issuer_id: i64, + client_id: i64, +) -> i64 { + conn.execute( + "INSERT INTO contracts ( + number, kind, issuer_id, client_id, title, effective_date, + governing_law, clause_pack, clause_pack_version + ) VALUES (?1, ?2, ?3, ?4, ?5, '2026-09-10', 'England and Wales', + 'standard', '2.0')", + params![ + number, + kind, + issuer_id, + client_id, + format!("{kind} contract") + ], + ) + .unwrap_or_else(|error| panic!("insert {kind} contract: {error}")); + conn.last_insert_rowid() +} + +#[test] +fn upgrades_populated_v7_database_without_losing_data_or_schema_objects() { + let database = tempfile::NamedTempFile::new().expect("create temporary database file"); + let database_path = database.path().to_path_buf(); + + let legacy = + finance_core::db::open_at(&database_path).expect("create finance-core V7 database"); + legacy + .execute( + "INSERT INTO issuers (slug, name, jurisdiction, address) + VALUES ('legacy-issuer', 'Legacy Issuer Ltd', 'uk', '1 Old Street')", + [], + ) + .expect("insert minimum valid issuer"); + let issuer_id = legacy.last_insert_rowid(); + legacy + .execute( + "INSERT INTO clients (slug, name, address) + VALUES ('legacy-client', 'Legacy Client Ltd', '2 Old Street')", + [], + ) + .expect("insert minimum valid client"); + let client_id = legacy.last_insert_rowid(); + let contract_id = + insert_minimal_contract(&legacy, "LEGACY-2026-0001", "nda", issuer_id, client_id); + legacy + .execute( + "INSERT INTO contract_clauses (contract_id, position, slug, heading, body) + VALUES (?1, 0, 'purpose', 'Legacy purpose', 'Preserve this wording.')", + [contract_id], + ) + .expect("insert legacy clause"); + legacy + .execute_batch( + "CREATE TABLE custom_contract_audit ( + id INTEGER PRIMARY KEY, + contract_id INTEGER NOT NULL, + contract_kind TEXT NOT NULL + ); + CREATE INDEX custom_contract_title_idx ON contracts(title); + CREATE TRIGGER custom_contract_insert_audit + AFTER INSERT ON contracts + BEGIN + INSERT INTO custom_contract_audit (contract_id, contract_kind) + VALUES (NEW.id, NEW.kind); + END;", + ) + .expect("create custom contract index and trigger"); + legacy + .execute( + "UPDATE sqlite_sequence SET seq = 9000 WHERE name = 'contracts'", + [], + ) + .expect("raise contracts AUTOINCREMENT watermark"); + drop(legacy); + + let upgraded = db::open_at(&database_path).expect("upgrade legacy contract schema"); + let contract = db::contract_get(&upgraded, "LEGACY-2026-0001") + .expect("migrated contract remains readable"); + assert_eq!(contract.id, contract_id); + assert_eq!(contract.kind, "nda"); + assert_eq!(contract.issuer_id, issuer_id); + assert_eq!(contract.client_id, client_id); + assert_eq!(contract.title, "nda contract"); + assert_eq!(contract.effective_date, "2026-09-10"); + assert_eq!(contract.governing_law, "England and Wales"); + assert_eq!(contract.status, "draft"); + assert_eq!(contract.terms_json, "{}"); + assert_eq!(contract.clause_pack, "standard"); + assert_eq!(contract.clause_pack_version, "2.0"); + + let migrated_clauses = + db::clauses_for(&upgraded, contract_id).expect("migrated clauses remain readable"); + assert_eq!(migrated_clauses.len(), 1); + assert_eq!(migrated_clauses[0].slug, "purpose"); + assert_eq!( + migrated_clauses[0].heading.as_deref(), + Some("Legacy purpose") + ); + assert_eq!( + migrated_clauses[0].body.as_deref(), + Some("Preserve this wording.") + ); + assert!(object_exists( + &upgraded, + "index", + "custom_contract_title_idx" + )); + assert!(object_exists( + &upgraded, + "trigger", + "custom_contract_insert_audit" + )); + assert_database_health(&upgraded); + let sequence: i64 = upgraded + .query_row( + "SELECT seq FROM sqlite_sequence WHERE name = 'contracts'", + [], + |row| row.get(0), + ) + .expect("read contracts sequence after migration"); + assert_eq!(sequence, 9000); + drop(upgraded); + + let reopened = db::open_at(&database_path).expect("reopen upgraded database idempotently"); + let contract_count: i64 = reopened + .query_row("SELECT COUNT(*) FROM contracts", [], |row| row.get(0)) + .expect("count contracts after reopening"); + let clause_count: i64 = reopened + .query_row("SELECT COUNT(*) FROM contract_clauses", [], |row| { + row.get(0) + }) + .expect("count clauses after reopening"); + assert_eq!(contract_count, 1); + assert_eq!(clause_count, 1); + assert!(object_exists( + &reopened, + "index", + "custom_contract_title_idx" + )); + assert!(object_exists( + &reopened, + "trigger", + "custom_contract_insert_audit" + )); + assert_database_health(&reopened); + let sequence: i64 = reopened + .query_row( + "SELECT seq FROM sqlite_sequence WHERE name = 'contracts'", + [], + |row| row.get(0), + ) + .expect("read contracts sequence after reopening"); + assert_eq!(sequence, 9000); + + let loan_id = + insert_minimal_contract(&reopened, "LOAN-2026-0001", "loan", issuer_id, client_id); + let ncnda_id = + insert_minimal_contract(&reopened, "NCNDA-2026-0001", "ncnda", issuer_id, client_id); + assert_eq!(loan_id, 9001, "migration preserves the high watermark"); + assert_eq!(ncnda_id, 9002); + let audit_count: i64 = reopened + .query_row("SELECT COUNT(*) FROM custom_contract_audit", [], |row| { + row.get(0) + }) + .expect("count custom trigger audit rows"); + assert_eq!(audit_count, 2, "restored trigger remains functional"); + + db::contract_delete(&reopened, "LEGACY-2026-0001", false) + .expect("delete migrated draft contract"); + let remaining_legacy_clauses: i64 = reopened + .query_row( + "SELECT COUNT(*) FROM contract_clauses WHERE contract_id = ?1", + [contract_id], + |row| row.get(0), + ) + .expect("count clauses after deleting migrated contract"); + assert_eq!( + remaining_legacy_clauses, 0, + "draft deletion cascades to clauses" + ); + assert_database_health(&reopened); +} + +fn assert_pack_is_self_consistent(pack: &clauses::Pack) { + assert!(!pack.pack.default_clauses.is_empty()); + for slug in &pack.pack.default_clauses { + assert!( + pack.clauses.contains_key(slug), + "{}/{} {} names missing default clause `{slug}`", + pack.pack.kind, + pack.pack.slug, + pack.pack.version + ); + } +} + +#[test] +fn embedded_clause_packs_and_historical_versions_remain_loadable() { + let packs = clauses::list_packs(); + assert!(!packs.is_empty(), "current pack discovery is non-empty"); + assert!( + packs.iter().all(|(kind, _)| kind != "archive"), + "historical archive is excluded from current-pack discovery" + ); + + for (kind, slug) in &packs { + let pack = clauses::load_pack(kind, slug) + .unwrap_or_else(|error| panic!("load current pack {kind}/{slug}: {error}")); + assert_eq!(&pack.pack.kind, kind); + assert_eq!(&pack.pack.slug, slug); + assert_pack_is_self_consistent(&pack); + } + + for (kind, version) in [ + ("consulting", "1.1"), + ("loan", "1.0"), + ("msa", "1.0"), + ("ncnda", "1.0"), + ("nda", "1.1"), + ("service", "1.0"), + ("sow", "1.0"), + ] { + let historical = clauses::load_pack_version(kind, "standard", version) + .unwrap_or_else(|error| panic!("load historical {kind}/standard {version}: {error}")); + assert_eq!(historical.pack.kind, kind); + assert_eq!(historical.pack.slug, "standard"); + assert_eq!(historical.pack.version, version); + assert_pack_is_self_consistent(&historical); + } + + let error = clauses::load_pack_version("nda", "standard", "0.0") + .expect_err("unknown historical version must be rejected"); + assert!( + error.to_string().contains("version 0.0 is unavailable"), + "unknown-version error identifies the unavailable version: {error}" + ); +} diff --git a/typst/shared/contract.typ b/typst/shared/contract.typ index 52708db..41ce01e 100644 --- a/typst/shared/contract.typ +++ b/typst/shared/contract.typ @@ -110,30 +110,33 @@ if m == none { s } else { s.slice(m.end) } } +// Parse block structure without eval. Continuation lines in lists must never +// disappear: legal text cannot be dropped because of its line wrapping. #let render-markdown(md) = { - let paras = md.split("\n\n").map(p => p.trim()) - for p in paras { - if p == "" { continue } - let lines = p.split("\n") - let first = lines.find(l => l.trim() != "") - if first == none { continue } - let first-t = first.trim() - if first-t.starts-with("- ") { - list( - ..lines - .filter(l => l.trim().starts-with("- ")) - .map(l => l.trim().slice(2)) - ) - } else if starts-with-number(first-t) { - enum( - ..lines - .filter(l => starts-with-number(l.trim())) - .map(l => strip-num-prefix(l.trim())) - ) - } else { - let joined = lines.map(l => l.trim()).filter(l => l != "").join(" ") - par(joined) + let groups = md.replace("\r\n", "\n").split("\n\n") + for group in groups { + let kind = "paragraph" + let items = () + let prose = () + let flush(kind, items, prose) = { + if items.len() > 0 { + if kind == "bullet" { list(..items) } else { enum(..items) } + } + if prose.len() > 0 { par(prose.join(" ")) } + } + for line in group.split("\n") { + let line = line.trim() + if line == "" { continue } + let next = if line.starts-with("- ") { "bullet" } else if starts-with-number(line) { "number" } else { "paragraph" } + if next != "paragraph" { + if kind != next { flush(kind, items, prose); items = (); prose = () } + kind = next + items.push(if next == "bullet" { line.slice(2) } else { strip-num-prefix(line) }) + } else if items.len() > 0 { + items.at(items.len() - 1) += " " + line + } else { prose.push(line) } } + flush(kind, items, prose) } } @@ -252,10 +255,10 @@ } #let signature-pair(theme, party-label, party-name, signer-name, signer-title, signer-date) = { - lbl(theme, "Signed for and on behalf of") + lbl(theme, "Signed by / for") v(sp.xs) text(font: th(theme, "display-font", ("Helvetica Neue", "Helvetica", "Arial")), size: 10.5pt, weight: 600)[#party-name] - v(16mm) + v(12mm) line(length: 100%, stroke: 0.4pt + th(theme, "ink", black)) v(sp.xxs) text(size: 6.5pt, fill: th(theme, "mute", rgb("#666666")), tracking: 0.8pt)[SIGNATURE] @@ -382,8 +385,9 @@ // ─── Page shell ──────────────────────────────────────────────────────────── #let page-shell(theme, body) = { + set document(title: data.kind-label, author: (), keywords: ("Agreement", data.kind), date: none) set page( - paper: "a4", + paper: data.at("paper", default: "a4"), margin: th(theme, "margin", (top: 22mm, bottom: 22mm, left: 22mm, right: 22mm)), fill: th(theme, "paper", white), header: context if here().page() > 1 { compact-strip(theme) }, diff --git a/typst/shared/modern.typ b/typst/shared/modern.typ new file mode 100644 index 0000000..c702730 --- /dev/null +++ b/typst/shared/modern.typ @@ -0,0 +1,68 @@ +#import "contract.typ": data, page-shell, hairline, render-markdown, signature-block, th + +// A document-first system: real headings, selectable text, flowing clauses, +// and an execution block that moves intact when space runs out. +#let modern-contract(theme, character: "folio") = { + set text(font: theme.body-font, size: 10pt, fill: theme.ink, lang: "en", hyphenate: false, number-type: "lining") + set par(leading: 4.5pt, spacing: 7pt, justify: false) + set list(indent: 12pt, body-indent: 5pt, spacing: 3pt) + set enum(indent: 12pt, body-indent: 5pt, spacing: 3pt) + set heading(numbering: none) + show heading.where(level: 1): it => block(above: 0pt, below: 0pt, sticky: true)[ + #text(font: theme.display-font, size: if character == "atelier" { 35pt } else if character == "counsel" { 27pt } else { 31pt }, weight: 500, tracking: -0.6pt)[#it.body] + ] + show heading.where(level: 2): it => block(above: 12pt, below: 4pt, sticky: true)[ + #text(font: theme.display-font, size: 10.3pt, weight: 600)[#it.body] + ] + page-shell(theme, [ + #if data.logo != none { image(data.logo, width: 22mm, height: 10mm, fit: "contain"); v(7mm) } + #heading(level: 1, data.kind-label) + #if data.subtitle != none { + v(4mm) + text(font: theme.body-font, size: 12pt, fill: theme.mute)[#data.subtitle] + } + #v(7mm) + #text(size: 9pt, fill: theme.mute)[Effective #data.effective-date-display] + #v(5mm) + #hairline(theme, weight: 0.6pt) + #v(4mm) + #for (i, party) in (data.our-party, data.their-party).enumerate() { + block(breakable: true, above: 3pt, below: 6pt)[ + #grid(columns: (17mm, 1fr), column-gutter: 4mm, + text(size: 8.5pt, fill: theme.mute)[#party.role-label], + [ + #text(weight: 600)[#if party.legal-name != none { party.legal-name } else { party.display-name }] + #linebreak() + #text(size: 9pt)[#party.address.join(", ")] + #if party.company-no != none { linebreak(); text(size: 8.5pt, fill: theme.mute)[Company no. #party.company-no] } + #if party.email != none { linebreak(); text(size: 8.5pt, fill: theme.mute)[#party.email] } + ] + ) + ] + } + #v(3mm) + #hairline(theme) + #v(4mm) + #let terms = (("Term", data.term-short), ("Governing law", data.governing-law)) + #if data.fee-short != none { terms += (("Fees", data.fee-short),) } + #for (label, value) in terms { + grid(columns: (28mm, 1fr), column-gutter: 3mm, + text(size: 8.5pt, fill: theme.mute)[#label], text(size: 9pt)[#value]) + v(2pt) + } + #v(4mm) + #hairline(theme) + #v(2mm) + #for clause in data.clauses { + heading(level: 2)[#clause.number. #clause.heading] + render-markdown(clause.body) + } + #block(breakable: false, above: 10mm)[ + #text(font: theme.display-font, size: 13pt, weight: 500)[Agreement and signatures] + #v(3mm) + #text(size: 9pt)[The parties agree to the terms set out above.] + #v(5mm) + #signature-block(data.signature, theme) + ] + ]) +} diff --git a/typst/templates/atelier.typ b/typst/templates/atelier.typ new file mode 100644 index 0000000..53731cb --- /dev/null +++ b/typst/templates/atelier.typ @@ -0,0 +1,12 @@ +//! description: Quiet design-studio agreements with expressive type, warm paper and precise spacing. +//! mood: warm, editorial, considered +//! tags: design, creative, studio, branding, architecture, warm +//! fonts: Newsreader 16pt, Libre Franklin (bundled OFL) +//! paper: A4 / warm white +#import "../shared/modern.typ": modern-contract +#let theme = ( + ink: rgb("#302B27"), paper: rgb("#FCFAF6"), accent: rgb("#765744"), mute: rgb("#655D55"), hair: rgb("#D8CFC4"), watermark: rgb("#EEE8E0"), + display-font: "Newsreader 16pt", body-font: "Libre Franklin", label-style: "upper", + margin: (top: 23mm, bottom: 25mm, left: 27mm, right: 27mm), +) +#modern-contract(theme, character: "atelier") diff --git a/typst/templates/counsel.typ b/typst/templates/counsel.typ new file mode 100644 index 0000000..b095ceb --- /dev/null +++ b/typst/templates/counsel.typ @@ -0,0 +1,12 @@ +//! description: Restrained legal typography with a literary serif and clear numbered clauses. +//! mood: formal, assured, legible +//! tags: legal, formal, lawyer, agreement, serif, classic +//! fonts: Literata, Libre Franklin (bundled OFL) +//! paper: A4 / white +#import "../shared/modern.typ": modern-contract +#let theme = ( + ink: rgb("#252525"), paper: white, accent: rgb("#252525"), mute: rgb("#595959"), hair: rgb("#CECECE"), watermark: rgb("#EEEEEE"), + display-font: "Literata", body-font: "Literata", label-style: "upper", + margin: (top: 25mm, bottom: 25mm, left: 28mm, right: 28mm), +) +#modern-contract(theme, character: "counsel") diff --git a/typst/templates/folio.typ b/typst/templates/folio.typ new file mode 100644 index 0000000..16e1497 --- /dev/null +++ b/typst/templates/folio.typ @@ -0,0 +1,12 @@ +//! description: Precise technology and startup agreements with generous type and clean white pages. +//! mood: precise, calm, contemporary +//! tags: technology, software, startup, modern, clean, white +//! fonts: Archivo, Libre Franklin (bundled OFL) +//! paper: A4 / white +#import "../shared/modern.typ": modern-contract +#let theme = ( + ink: rgb("#202722"), paper: white, accent: rgb("#33483E"), mute: rgb("#58615B"), hair: rgb("#CCD3CE"), watermark: rgb("#EDF0ED"), + display-font: "Archivo", body-font: "Libre Franklin", label-style: "upper", + margin: (top: 22mm, bottom: 24mm, left: 26mm, right: 26mm), +) +#modern-contract(theme) From dd50bc4a06da819e929502a6f49a8bd557a9d5d1 Mon Sep 17 00:00:00 2001 From: Paperfoot Date: Thu, 10 Sep 2026 23:20:57 +0100 Subject: [PATCH 2/3] Unify typography and pagination across every contract template --- CHANGELOG.md | 1 + README.md | 6 +- clauses/consulting/design.toml | 36 ++++-- clauses/consulting/standard.toml | 28 +++-- clauses/consulting/technology.toml | 36 ++++-- clauses/loan/standard.toml | 16 +-- clauses/msa/standard.toml | 26 ++++- clauses/msa/startup.toml | 26 ++++- clauses/ncnda/standard.toml | 20 ++-- clauses/nda/standard.toml | 22 ++-- clauses/service/standard.toml | 30 +++-- clauses/sow/standard.toml | 12 +- docs/TYPOGRAPHY.md | 33 ++++++ scripts/smoke-pdfs.py | 124 ++++++++++++++++++++- src/commands/template.rs | 3 +- src/render.rs | 14 ++- typst/shared/contract.typ | 79 ++++++------- typst/shared/modern.typ | 172 +++++++++++++++++++---------- typst/templates/atelier.typ | 10 +- typst/templates/basel.typ | 159 ++------------------------ typst/templates/chancery.typ | 150 ++----------------------- typst/templates/counsel.typ | 10 +- typst/templates/editorial.typ | 123 ++------------------- typst/templates/folio.typ | 12 +- typst/templates/gazette.typ | 144 ++---------------------- typst/templates/helvetica-nera.typ | 120 ++------------------ typst/templates/marrakech.typ | 132 ++-------------------- typst/templates/vienna-legal.typ | 131 ++-------------------- 28 files changed, 576 insertions(+), 1099 deletions(-) create mode 100644 docs/TYPOGRAPHY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2876fc0..857d635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.3.0 +- Rebuild all ten PDF templates around a shared reading grid, optically sized bundled fonts, hanging clause numbers, deliberate paragraph/list spacing, readable running furniture and consistent signature fields. Keep list introductions with their first item and short list items intact across page breaks. - Add explicit UK, US, Singapore and global legal profiles; validate state, governing law and venue choices. - Add folio, counsel and atelier PDF designs, selectable A4/US Letter, embedded font fallbacks and semantic headings in the new designs. - Add technology, design and startup clause packs. Refresh standard packs for liability, IP, confidentiality, notices, data processing and execution; retain the preceding pack versions in an archive. diff --git a/README.md b/README.md index 1dab774..14af236 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,9 @@ contract template preview atelier --kind consulting --pack design --out ./design contract template find "quiet legal serif" ``` -The original `helvetica-nera`, `vienna-legal`, `editorial`, `gazette`, `marrakech`, `basel` and `chancery` designs remain available. The default remains `helvetica-nera`. +All ten templates use one shared layout: a 142 mm reading column on A4, hanging clause numbers, flush-left paragraphs with visible separation, aligned lists and intact signature blocks. The original `helvetica-nera`, `vienna-legal`, `editorial`, `gazette`, `marrakech`, `basel` and `chancery` names remain available with rebuilt layouts. The default remains `helvetica-nera`. -Template resolution is `--template`, then the contract's stored template, then a valid shared config template, then `helvetica-nera`. A shared invoice-only template is skipped. `config set default_template folio` changes the shared accounting configuration. Embedded fonts carry OFL licences; older designs may use system fonts before their embedded fallbacks, so appearance can differ across machines. +Template resolution is `--template`, then the contract's stored template, then a valid shared config template, then `helvetica-nera`. A shared invoice-only template is skipped. `config set default_template folio` changes the shared accounting configuration. Every stock template uses bundled OFL fonts. Body, headings, labels and signatures share the same family within each design; Gazette adds a separate display face for its title. The typography rules and review criteria are in [the design guide](docs/TYPOGRAPHY.md). ## Compose clauses @@ -139,7 +139,7 @@ cargo build --locked python3 scripts/smoke-pdfs.py --binary target/debug/contract ``` -The PDF smoke check renders every template/kind combination and the specialised packs, verifies actual clause text and unresolved variables, and checks US Letter dimensions when `pdfinfo` is available. A long custom-document case also checks wrapped lists, private-note exclusion, missing-term rejection and preservation of an existing PDF after compiler failure. Tests isolate HOME and XDG state. The migration tests preserve populated legacy records, custom indexes/triggers, foreign keys and sequence values. +The PDF smoke check renders every template/kind combination and the specialised packs, verifies actual clause text, text bounds and unresolved variables, and checks US Letter dimensions when `pdfinfo` is available. Long-party cases verify wrapping and full legal names. A long custom-document case also checks wrapped lists, private-note exclusion, missing-term rejection and preservation of an existing PDF after compiler failure. Tests isolate HOME and XDG state. The migration tests preserve populated legacy records, custom indexes/triggers, foreign keys and sequence values. See [changes](CHANGELOG.md) and [legal scope](docs/LEGAL.md). These are drafting starting points for two-party business agreements, not a substitute for legal advice or prescribed regulated documents. diff --git a/clauses/consulting/design.toml b/clauses/consulting/design.toml index 7698f8e..fe46748 100644 --- a/clauses/consulting/design.toml +++ b/clauses/consulting/design.toml @@ -28,7 +28,7 @@ If the Client wants something materially outside this list, the parties will agr """ [clauses.fees_and_expenses] -heading = "Fees and Expenses" +heading = "Fees and expenses" body = """ The Client will pay the Consultant {{fee_text}}. @@ -42,7 +42,7 @@ The engagement begins on {{effective_date}} and continues {{term_text}}. The Con """ [clauses.relationship] -heading = "Independent Contractor" +heading = "Independent contractor" body = """ The Consultant is an independent contractor, not an employee, agent, partner, or joint-venture partner of the Client. Nothing in this agreement creates such a relationship. The Consultant is responsible for its own taxes, social contributions, and insurance, and may engage subcontractors provided the Consultant remains responsible for their work. """ @@ -56,7 +56,13 @@ body = """ [clauses.confidentiality] heading = "Confidentiality" body = """ -Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. + +These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. + +Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. + +The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. """ [clauses.warranties] @@ -82,15 +88,21 @@ Either party may terminate this agreement for convenience by giving the other at [clauses.general] heading = "General provisions" body = """ -This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. + +A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. + +Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. -Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. + +This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. """ [clauses.non_solicit] -heading = "Non-Solicitation" +heading = "Non-solicitation" body = """ While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact under this engagement. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. """ @@ -98,7 +110,9 @@ While this agreement is in force and for 12 months after it ends, neither party [clauses.data_protection] heading = "Data protection and security" body = """ -Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. + +Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. """ @@ -106,7 +120,13 @@ Neither party may put the other party's confidential information or personal dat [clauses.acceptance] heading = "Review and acceptance" body = """ -Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. Acceptance does not waive rights relating to latent defects or express warranties. Changes to agreed criteria or additional work require a written change order setting out fees and timing. +Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. + +The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. + +Acceptance does not waive rights relating to latent defects or express warranties. + +Changes to agreed criteria or additional work require a written change order setting out fees and timing. """ [clauses.creative_scope] diff --git a/clauses/consulting/standard.toml b/clauses/consulting/standard.toml index bf8e774..8ead70d 100644 --- a/clauses/consulting/standard.toml +++ b/clauses/consulting/standard.toml @@ -28,7 +28,7 @@ If the Client wants something materially outside this list, the parties will agr """ [clauses.fees_and_expenses] -heading = "Fees and Expenses" +heading = "Fees and expenses" body = """ The Client will pay the Consultant {{fee_text}}. @@ -42,7 +42,7 @@ The engagement begins on {{effective_date}} and continues {{term_text}}. The Con """ [clauses.relationship] -heading = "Independent Contractor" +heading = "Independent contractor" body = """ The Consultant is an independent contractor, not an employee, agent, partner, or joint-venture partner of the Client. Nothing in this agreement creates such a relationship. The Consultant is responsible for its own taxes, social contributions, and insurance, and may engage subcontractors provided the Consultant remains responsible for their work. """ @@ -56,7 +56,13 @@ body = """ [clauses.confidentiality] heading = "Confidentiality" body = """ -Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. + +These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. + +Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. + +The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. """ [clauses.warranties] @@ -82,15 +88,21 @@ Either party may terminate this agreement for convenience by giving the other at [clauses.general] heading = "General provisions" body = """ -This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. + +A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. -Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. +Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. + +This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. """ [clauses.non_solicit] -heading = "Non-Solicitation" +heading = "Non-solicitation" body = """ While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact under this engagement. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. """ @@ -98,7 +110,9 @@ While this agreement is in force and for 12 months after it ends, neither party [clauses.data_protection] heading = "Data protection and security" body = """ -Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. + +Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. """ diff --git a/clauses/consulting/technology.toml b/clauses/consulting/technology.toml index 5686423..31678bc 100644 --- a/clauses/consulting/technology.toml +++ b/clauses/consulting/technology.toml @@ -28,7 +28,7 @@ If the Client wants something materially outside this list, the parties will agr """ [clauses.fees_and_expenses] -heading = "Fees and Expenses" +heading = "Fees and expenses" body = """ The Client will pay the Consultant {{fee_text}}. @@ -42,7 +42,7 @@ The engagement begins on {{effective_date}} and continues {{term_text}}. The Con """ [clauses.relationship] -heading = "Independent Contractor" +heading = "Independent contractor" body = """ The Consultant is an independent contractor, not an employee, agent, partner, or joint-venture partner of the Client. Nothing in this agreement creates such a relationship. The Consultant is responsible for its own taxes, social contributions, and insurance, and may engage subcontractors provided the Consultant remains responsible for their work. """ @@ -56,7 +56,13 @@ body = """ [clauses.confidentiality] heading = "Confidentiality" body = """ -Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. + +These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. + +Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. + +The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. """ [clauses.warranties] @@ -82,15 +88,21 @@ Either party may terminate this agreement for convenience by giving the other at [clauses.general] heading = "General provisions" body = """ -This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. + +A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. + +Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. -Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. + +This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. """ [clauses.non_solicit] -heading = "Non-Solicitation" +heading = "Non-solicitation" body = """ While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact under this engagement. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. """ @@ -98,7 +110,9 @@ While this agreement is in force and for 12 months after it ends, neither party [clauses.data_protection] heading = "Data protection and security" body = """ -Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. + +Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. """ @@ -106,7 +120,13 @@ Neither party may put the other party's confidential information or personal dat [clauses.acceptance] heading = "Review and acceptance" body = """ -Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. Acceptance does not waive rights relating to latent defects or express warranties. Changes to agreed criteria or additional work require a written change order setting out fees and timing. +Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. + +The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. + +Acceptance does not waive rights relating to latent defects or express warranties. + +Changes to agreed criteria or additional work require a written change order setting out fees and timing. """ [clauses.delivery_security] diff --git a/clauses/loan/standard.toml b/clauses/loan/standard.toml index 5466ed2..119cae4 100644 --- a/clauses/loan/standard.toml +++ b/clauses/loan/standard.toml @@ -6,7 +6,7 @@ kind = "loan" default_clauses = ["the_loan", "advance", "interest", "repayment", "prepayment", "default", "set_off", "costs", "assignment", "notices", "no_partnership", "third_party_rights", "entire_agreement", "counterparts", "governing_law", "regulatory_scope"] [clauses.the_loan] -heading = "The Loan" +heading = "The loan" body = """ {{our_legal_name}} (the “Lender”) agrees to lend {{their_legal_name}} (the “Borrower”) {{principal_text}} (the “Loan”). If no amount is stated above, the “Loan” means the total amount the Lender actually advances to the Borrower under this agreement. """ @@ -36,7 +36,7 @@ The Borrower may repay the Loan early, in whole or in part, at any time and with """ [clauses.default] -heading = "Default and Acceleration" +heading = "Default and acceleration" body = """ Each of the following is an “Event of Default”: @@ -48,7 +48,7 @@ If an Event of Default occurs, the Lender may, by written notice, declare the wh """ [clauses.set_off] -heading = "Payments Without Set-Off" +heading = "Payments without set-off" body = """ All payments by the Borrower under this agreement must be made in full, without set-off, counterclaim, deduction, or withholding, except for any deduction or withholding required by law. """ @@ -72,31 +72,31 @@ Notices must be in writing and sent to the contact email or postal address in th """ [clauses.no_partnership] -heading = "No Partnership" +heading = "No partnership" body = """ Nothing in this agreement creates a partnership, joint venture, agency, or employment relationship between the parties, and the Lender owes the Borrower no duties as an advisor or fiduciary. """ [clauses.third_party_rights] -heading = "Third-Party Rights" +heading = "Third-party rights" body = """ A person who is not a party to this agreement has no right to enforce any of its terms, except where mandatory law provides otherwise. """ [clauses.entire_agreement] -heading = "Entire Agreement" +heading = "Entire agreement" body = """ This agreement is the entire agreement between the parties on the Loan and replaces prior discussions on the same subject. Changes must be in writing and signed by both parties. Nothing excludes or limits liability for fraud, fraudulent misrepresentation or any liability that cannot lawfully be excluded. """ [clauses.counterparts] -heading = "Counterparts and Electronic Signatures" +heading = "Counterparts and electronic signatures" body = """ This agreement may be signed in any number of counterparts, each of which is an original and which together form one agreement. Signatures exchanged electronically — including scanned and e-signed copies — may be used where permitted by applicable law and all required execution formalities are met. """ [clauses.governing_law] -heading = "Governing Law and Jurisdiction" +heading = "Governing law and jurisdiction" body = """ This agreement, and any dispute arising out of or in connection with it, are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}. """ diff --git a/clauses/msa/standard.toml b/clauses/msa/standard.toml index 9b9a519..22c8ba0 100644 --- a/clauses/msa/standard.toml +++ b/clauses/msa/standard.toml @@ -12,13 +12,13 @@ body = """ """ [clauses.services_via_sow] -heading = "Services Under SOWs" +heading = "Services under SOWs" body = """ From time to time, the parties may sign one or more SOWs that reference this agreement. Each SOW describes the services, deliverables, fees, timing, and any project-specific terms. Each signed SOW is incorporated into this agreement. If there is a conflict between this agreement and an SOW, the SOW controls for that project unless the SOW says otherwise. """ [clauses.fees_general] -heading = "Fees and Expenses (General)" +heading = "Fees and expenses (general)" body = """ Fees, payment schedules, and expense rules are set in each SOW. Unless an SOW says otherwise, invoices are payable within 14 days of receipt and reasonable pre-approved out-of-pocket expenses are reimbursable. Taxes are charged in addition to fees where the Provider is required to charge them. """ @@ -32,7 +32,13 @@ This agreement takes effect on {{effective_date}} and continues {{term_text}}. E [clauses.confidentiality] heading = "Confidentiality" body = """ -Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. + +These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. + +Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. + +The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. """ [clauses.ip_ownership] @@ -64,9 +70,15 @@ Either party may terminate an SOW immediately for material breach that is not cu [clauses.general] heading = "General provisions" body = """ -This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. + +A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. -Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. +Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. + +This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. """ @@ -74,7 +86,9 @@ This agreement and non-contractual obligations arising from it are governed by t [clauses.data_protection] heading = "Data protection and security" body = """ -Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. + +Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. """ diff --git a/clauses/msa/startup.toml b/clauses/msa/startup.toml index d006260..92f64d7 100644 --- a/clauses/msa/startup.toml +++ b/clauses/msa/startup.toml @@ -12,13 +12,13 @@ body = """ """ [clauses.services_via_sow] -heading = "Services Under SOWs" +heading = "Services under SOWs" body = """ From time to time, the parties may sign one or more SOWs that reference this agreement. Each SOW describes the services, deliverables, fees, timing, and any project-specific terms. Each signed SOW is incorporated into this agreement. If there is a conflict between this agreement and an SOW, the SOW controls for that project unless the SOW says otherwise. """ [clauses.fees_general] -heading = "Fees and Expenses (General)" +heading = "Fees and expenses (general)" body = """ Fees, payment schedules, and expense rules are set in each SOW. Unless an SOW says otherwise, invoices are payable within 14 days of receipt and reasonable pre-approved out-of-pocket expenses are reimbursable. Taxes are charged in addition to fees where the Provider is required to charge them. """ @@ -32,7 +32,13 @@ This agreement takes effect on {{effective_date}} and continues {{term_text}}. E [clauses.confidentiality] heading = "Confidentiality" body = """ -Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. + +These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. + +Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. + +The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. """ [clauses.ip_ownership] @@ -64,9 +70,15 @@ Either party may terminate an SOW immediately for material breach that is not cu [clauses.general] heading = "General provisions" body = """ -This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. + +A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. -Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. +Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. + +This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. """ @@ -74,7 +86,9 @@ This agreement and non-contractual obligations arising from it are governed by t [clauses.data_protection] heading = "Data protection and security" body = """ -Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. + +Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. """ diff --git a/clauses/ncnda/standard.toml b/clauses/ncnda/standard.toml index 52abb1d..832dca8 100644 --- a/clauses/ncnda/standard.toml +++ b/clauses/ncnda/standard.toml @@ -35,7 +35,7 @@ Neither party will make any public statement about the Purpose, this agreement, """ [clauses.permitted_disclosures] -heading = "Permitted Disclosures" +heading = "Permitted disclosures" body = """ A Receiving Party may disclose Confidential Information to Representatives who need it for the Purpose and owe equivalent confidentiality duties; the Receiving Party remains responsible for their compliance. Disclosure required by law, regulation or a competent authority is permitted to the minimum extent required, with prior notice where lawful and practicable. @@ -43,7 +43,7 @@ Nothing restricts reporting suspected crime, protected whistleblowing, cooperati """ [clauses.non_circumvention] -heading = "Non-Circumvention" +heading = "Non-circumvention" body = """ For {{non_circumvention_months}} months from the date of this agreement, neither party shall knowingly use a recorded Introduction or Confidential Information to bypass the introducing party in the specific opportunity described in that Introduction, with the purpose of avoiding a role or remuneration expressly agreed in writing with the introducing party. Each party remains responsible for its Representatives acting on its behalf. @@ -51,13 +51,13 @@ This clause does not prohibit general competition, independent opportunities, pr """ [clauses.introducer_role] -heading = "Introductions and Remuneration" +heading = "Introductions and remuneration" body = """ Any commission or other remuneration must be expressly agreed in a separate written fee schedule specifying the payer, amount or calculation, trigger, timing and applicable taxes. The current agreed position is: {{commission_text}}. This agreement does not create an implied fee, exclusivity, authority to bind another party, or authority to undertake regulated investment, financial or other intermediary activity. Any required authorisation must be obtained before that activity begins. """ [clauses.prior_dealings] -heading = "Prior and Independent Contacts" +heading = "Prior and independent contacts" body = """ Nothing prevents a party from pursuing a contact or opportunity it can demonstrate it already knew or was independently developing before the Introduction, or subsequently obtains lawfully without using the other party's Confidential Information. Contemporaneous records may establish such circumstances. """ @@ -71,25 +71,25 @@ Without limiting any other remedy, if a party concludes or facilitates a transac """ [clauses.no_obligation] -heading = "No Commitment, No Licence" +heading = "No commitment, no licence" body = """ Neither party is obliged by this agreement to proceed with the Purpose or any transaction, or to share any particular information; either party may end discussions at any time. All Confidential Information remains the property of the Disclosing Party. No licence or other right is granted beyond the limited right to use it for the Purpose. It is provided “as is”, without warranty as to accuracy or completeness. """ [clauses.term] -heading = "Term and Survival" +heading = "Term and survival" body = """ This agreement takes effect on {{effective_date}} and continues {{term_text}}. Confidentiality duties survive for {{confidentiality_years}} years after this agreement ends, and for trade secrets while they remain protected under applicable law. The non-circumvention period is measured from the agreement date as stated in that clause and is not extended by termination. On request, Confidential Information shall be returned or deleted, except legally required records and inaccessible routine backups, which remain protected. """ [clauses.entire_agreement] -heading = "Entire Agreement" +heading = "Entire agreement" body = """ This agreement is the entire agreement on its subject and supersedes earlier discussions. Nothing excludes or limits liability for fraud, fraudulent misrepresentation or any liability that cannot lawfully be excluded. """ [clauses.variation] -heading = "Variation and Waiver" +heading = "Variation and waiver" body = """ Changes to this agreement must be in writing and signed by both parties. A failure or delay in exercising a right is not a waiver of it. """ @@ -119,7 +119,7 @@ Notices must be in writing and sent to the contact email or postal address in th """ [clauses.no_partnership] -heading = "No Partnership" +heading = "No partnership" body = """ Nothing in this agreement creates a partnership, joint venture, agency, or employment relationship between the parties. """ @@ -131,7 +131,7 @@ This agreement may be signed in counterparts and by electronic signature where a """ [clauses.governing_law] -heading = "Governing Law and Jurisdiction" +heading = "Governing law and jurisdiction" body = """ This agreement, and any non-contractual obligations arising out of or in connection with it, are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, except that either party may seek injunctive or protective relief in any court of competent jurisdiction. """ diff --git a/clauses/nda/standard.toml b/clauses/nda/standard.toml index 1f56ba4..68251ca 100644 --- a/clauses/nda/standard.toml +++ b/clauses/nda/standard.toml @@ -20,7 +20,7 @@ Confidential Information includes technical, commercial and financial informatio """ [clauses.obligations] -heading = "How the Information Must Be Treated" +heading = "How the information must be treated" body = """ The receiving party will: @@ -31,7 +31,7 @@ The receiving party will: """ [clauses.exclusions] -heading = "What Is Not Covered" +heading = "What is not covered" body = """ These obligations do not apply to information that: @@ -44,7 +44,7 @@ If a court, regulator, or law forces disclosure, the receiving party may comply, """ [clauses.permitted_disclosures] -heading = "Sharing With Advisors" +heading = "Sharing with advisors" body = """ The receiving party may share the Confidential Information with its directors, employees, and professional advisors (such as lawyers and accountants) on a need-to-know basis, provided those people are bound by obligations of confidentiality at least as protective as this agreement. The receiving party remains responsible for any breach by anyone it shares the information with. """ @@ -56,19 +56,19 @@ This agreement takes effect on {{effective_date}} and continues {{term_text}}. E """ [clauses.return_or_destroy] -heading = "Return or Destruction" +heading = "Return or destruction" body = """ On written request from the disclosing party, or when this agreement ends, the receiving party will promptly return or destroy all Confidential Information in its possession (including copies), other than (i) one archive copy retained by its legal or compliance function and (ii) routine electronic backups that cannot be reasonably deleted. Anything retained remains subject to this agreement. """ [clauses.no_license] -heading = "No Licence, No Warranty" +heading = "No licence, no warranty" body = """ Nothing in this agreement gives either party any rights — by licence, ownership, or otherwise — in the other party’s Confidential Information or intellectual property. Confidential Information is provided “as is”. Neither party makes any warranty as to its accuracy or completeness. """ [clauses.no_obligation] -heading = "No Commitment" +heading = "No commitment" body = """ Neither party is obliged by this agreement to enter into any further agreement, to share any specific information, or to pursue the Purpose. Either party may end its participation in discussions at any time. """ @@ -80,7 +80,7 @@ Both parties acknowledge that a breach of this agreement may cause irreparable h """ [clauses.governing_law] -heading = "Governing Law" +heading = "Governing law" body = """ This agreement is governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}} for any dispute arising out of or in connection with it. """ @@ -92,7 +92,7 @@ This agreement is the entire agreement on its subject and supersedes earlier dis """ [clauses.variation] -heading = "Variation and Waiver" +heading = "Variation and waiver" body = """ Changes to this agreement must be in writing and signed by both parties. A failure or delay in exercising a right is not a waiver of it. """ @@ -128,19 +128,19 @@ A person who is not a party to this agreement has no right to enforce its terms, """ [clauses.no_partnership] -heading = "No Partnership" +heading = "No partnership" body = """ Nothing in this agreement creates a partnership, joint venture, agency, or employment relationship between the parties. """ [clauses.non_circumvention] -heading = "Non-Circumvention" +heading = "Non-circumvention" body = """ Neither party will use the other’s Confidential Information, or any introduction made under this agreement, to circumvent or bypass the other party in relation to any contact, counterparty, or opportunity first made known to it by the other party — including by dealing with that contact directly so as to exclude the other party from a transaction or from remuneration separately agreed with it. This obligation applies during the term of this agreement and for {{non_circumvention_months}} months after it ends; if no period is stated, it applies for as long as the confidentiality obligations survive. """ [clauses.non_solicit] -heading = "Non-Solicitation" +heading = "Non-solicitation" body = """ While this agreement is in force and for 12 months after it ends, neither party will actively solicit for employment or engagement any employee or contractor of the other with whom it had material contact in connection with the Purpose. General recruitment advertising not targeted at those people — and hiring anyone who responds to it — is not a breach. """ diff --git a/clauses/service/standard.toml b/clauses/service/standard.toml index 6467a96..0ed0162 100644 --- a/clauses/service/standard.toml +++ b/clauses/service/standard.toml @@ -22,25 +22,25 @@ The Provider will supply: {{purpose}}. [clauses.fees] heading = "Fees" body = """ -{{fee_text}}. +The Customer will pay the Provider {{fee_text}}. Fees are payable within 14 days of invoice. Statutory interest and recovery costs may be claimed only to the extent available under applicable law; no additional contractual default interest is imposed. Taxes are added where the Provider is required to charge them. """ [clauses.term_renewal] -heading = "Term and Renewal" +heading = "Term and renewal" body = """ This agreement starts on {{effective_date}} and continues {{term_text}}. A fixed term ends on expiry unless the parties expressly agree a renewal in writing, including its length, fees and notice requirements. There is no automatic renewal. An indefinite engagement may be terminated under the Termination clause. """ [clauses.support_changes] -heading = "Support and Changes" +heading = "Support and changes" body = """ The Provider will use commercially reasonable efforts to keep the services available and to respond to support requests during normal business hours. The Provider may change non-material features from time to time. Material changes that reduce the services available to the Customer will be communicated in advance, and the Customer may terminate this agreement (without fee) within 30 days of such a change. """ [clauses.data_handling] -heading = "Data Handling" +heading = "Data handling" body = """ The Provider will handle Customer data with reasonable care, will not use it for any purpose other than providing the services and complying with law, and will apply appropriate technical and organisational security measures. The Customer remains the owner of its data. On termination, the Provider will return or delete Customer data within a reasonable period, except as required by law or routine backups. """ @@ -68,9 +68,15 @@ Either party may terminate this agreement immediately for material breach not cu [clauses.general] heading = "General provisions" body = """ -This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. +This agreement is the entire agreement on its subject and supersedes earlier discussions; nothing excludes liability for fraud or fraudulent misrepresentation. Amendments must be in writing and agreed by authorised representatives of both parties. An exchange of emails expressly agreeing a change is sufficient, except where law requires a different formality. -Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. +A delay in enforcing a right is not a waiver. If a provision is unenforceable, the remaining provisions continue in effect. + +Assignment of rights requires the other party's written consent, not to be unreasonably withheld; transfer of obligations requires a written novation agreed by all affected parties. No person other than the parties may enforce this agreement except where mandatory law provides otherwise. + +Notices shall be sent to the contact email or postal address identified in the Parties section, or a replacement notified in writing. Email notices take effect when receipt is acknowledged by the recipient, excluding automated responses; if not acknowledged, the sender must use delivery with evidence of receipt. This notice method does not govern service of court proceedings. + +This agreement may be signed in counterparts and by electronic signature where permitted by law. Each signatory confirms authority to bind the relevant party. Recording a signatory's name in software does not itself establish consent or authority. This agreement and non-contractual obligations arising from it are governed by the laws of {{governing_law}}. The parties submit to the exclusive jurisdiction of {{jurisdiction_phrase}}, without preventing urgent interim relief from another court with competent jurisdiction. """ @@ -78,13 +84,21 @@ This agreement and non-contractual obligations arising from it are governed by t [clauses.confidentiality] heading = "Confidentiality" body = """ -Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. +Each party shall protect the other party's non-public information with reasonable care, use it only to perform this agreement, and disclose it only to personnel, subcontractors and professional advisers who need it for that purpose and owe equivalent duties of confidence. Each party remains responsible for those recipients. + +These duties do not cover information demonstrably public without breach, already lawfully known without restriction, independently developed, or lawfully received from a third party without restriction. + +Legally required disclosures are permitted to the minimum extent required, with prior notice where lawful. Nothing restricts protected disclosures to regulators, law enforcement or legal advisers, or the reporting of wrongdoing protected by applicable law. + +The duties last for {{confidentiality_years}} years after termination, and for trade secrets while they remain protected by applicable law. On request, confidential material shall be returned or deleted, subject to legal retention and inaccessible routine backups, which remain protected. """ [clauses.data_protection] heading = "Data protection and security" body = """ -Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. +Each party shall comply with applicable data protection law. The parties shall identify their respective controller and processor roles before sharing personal data. + +Where one party processes personal data on behalf of the other, processing must not begin until they have signed a data processing schedule covering the subject matter, duration, purpose, data types and data subjects; documented instructions; confidentiality; security; approved subprocessors; assistance with rights, breaches and impact assessments; audit information; deletion or return; and lawful international transfers. This agreement alone does not authorise such processing or supply that schedule. Neither party may put the other party's confidential information or personal data into a public or third-party AI service without prior written authorisation and agreement on retention, training use, security and subprocessors. Each party shall notify the other without undue delay of a security incident affecting the other's information and cooperate in containment and recovery. """ diff --git a/clauses/sow/standard.toml b/clauses/sow/standard.toml index 4dc4df1..15b2cea 100644 --- a/clauses/sow/standard.toml +++ b/clauses/sow/standard.toml @@ -12,7 +12,7 @@ This SOW is entered into by {{our_legal_name}} (the “Provider”) and {{their_ """ [clauses.description] -heading = "Project Description" +heading = "Project description" body = """ {{purpose}} """ @@ -28,7 +28,7 @@ The Provider will deliver the following: [clauses.fees] heading = "Fees" body = """ -{{fee_text}}. +The Client will pay the Provider {{fee_text}}. Invoices are payable within 14 days of receipt. Reasonable out-of-pocket expenses are reimbursable when pre-approved in writing. """ @@ -42,7 +42,13 @@ Work begins on {{effective_date}} and runs {{term_text}}. Specific milestones, i [clauses.acceptance] heading = "Review and acceptance" body = """ -Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. Acceptance does not waive rights relating to latent defects or express warranties. Changes to agreed criteria or additional work require a written change order setting out fees and timing. +Acceptance shall be assessed against the deliverables and objective acceptance criteria expressly agreed in writing. Within {{acceptance_days}} business days after delivery, the {{their_role}} shall confirm acceptance or give written details of material non-conformities against those criteria. + +The {{our_role}} shall correct those non-conformities at its own cost within a reasonable period and resubmit for review. Silence alone is not acceptance. A minor defect that does not materially prevent the agreed use shall not justify rejecting otherwise conforming work, but must be corrected. + +Acceptance does not waive rights relating to latent defects or express warranties. + +Changes to agreed criteria or additional work require a written change order setting out fees and timing. """ [clauses.assumptions] diff --git a/docs/TYPOGRAPHY.md b/docs/TYPOGRAPHY.md new file mode 100644 index 0000000..48624c9 --- /dev/null +++ b/docs/TYPOGRAPHY.md @@ -0,0 +1,33 @@ +# Document typography + +Every stock template uses `typst/shared/modern.typ`. Contract kind and clause pack change the content, not the layout rules. Themes set bundled fonts, ink and paper colours; page geometry, spacing and execution fields stay shared. + +## Reading grid + +- A4 and US Letter use 30 mm horizontal margins and 25 mm vertical margins. +- An 8 mm hanging-number gutter leaves a 142 mm reading column on A4. Headings, paragraphs, party details and running furniture share that text axis. +- Paragraphs are flush left without first-line indents. The 5.5 pt line gap and 11.5 pt paragraph gap are ink-to-ink measurements, not baseline distances. Literata receives one extra point in both gaps for its larger apparent letter height. +- Clause headings use the body family in semibold, one point larger than the body, with 19 pt above and 7 pt below. The signature heading uses the same size and weight. +- List continuation lines align with their item text. Introductions stay with the first item; normal items move intact while long custom items can flow across pages. A short terminal word stays with its predecessor. +- Signature fields use readable 9.5 pt labels and approximately 9 mm blank writing rows. The execution block never splits. A closing clause stays with it when their measured combined height fits comfortably on a page. Short paragraphs also move intact. + +## Font roles + +The sans designs and Literata designs use 11.25 pt body text. Newsreader uses 12 pt and EB Garamond 12.5 pt to compensate for their smaller apparent letter height. These are optical adjustments within one hierarchy. Titles use 27 pt; labels 9.5 pt; running headers and page numbers 8.5 pt. Gazette alone uses a separate title family. + +| Templates | Family | +|---|---| +| Folio, Vienna Legal | Libre Franklin | +| Helvetica Nera, Basel | Archivo | +| Counsel, Editorial, Marrakech | Literata | +| Atelier | Newsreader | +| Gazette | Newsreader; Fraunces title | +| Chancery | EB Garamond | + +Template names are retained for compatibility. Helvetica Nera now uses bundled Archivo, so it no longer depends on an installed Helvetica face. These design names do not imply deed, witnessing or notarisation functionality. + +## Review changes as documents + +Run `python3 scripts/smoke-pdfs.py` after changing shared typography. It renders every template and document kind, alternate packs, Letter output, long custom content and long party names. Text extraction verifies content; it cannot establish visual quality. + +Render and inspect first, middle and final pages at print scale as well. Check heading and body alignment, paragraph rhythm, list continuations, long party names, header/footer clearance and writable signatures. Do not compress type or spacing to hit an arbitrary page count. Never change legal wording merely to make a page fit. diff --git a/scripts/smoke-pdfs.py b/scripts/smoke-pdfs.py index ce3b297..30e4362 100644 --- a/scripts/smoke-pdfs.py +++ b/scripts/smoke-pdfs.py @@ -13,6 +13,7 @@ import sys import tempfile import unicodedata +import xml.etree.ElementTree as ET EXPECTED_TEMPLATES = { @@ -127,8 +128,21 @@ def inspect_pdf(pdf: Path, pdftotext: str, markers: tuple[str, ...]) -> None: if handle.read(5) != b"%PDF-": raise SmokeFailure(f"invalid PDF header: {pdf.name}") - result = run_checked([pdftotext, "-enc", "UTF-8", str(pdf), "-"]) - text = normalized(result.stdout) + # Body text may span pages. Exclude running furniture by position before + # joining words, otherwise a page number can falsely break a clause marker. + result = run_checked([pdftotext, "-bbox", "-enc", "UTF-8", str(pdf), "-"]) + document = ET.fromstring(result.stdout) + body_words = [] + for page in document.findall(".//{*}page"): + width, height = float(page.attrib["width"]), float(page.attrib["height"]) + for word in page.findall(".//{*}word"): + x0, x1 = float(word.attrib["xMin"]), float(word.attrib["xMax"]) + y0, y1 = float(word.attrib["yMin"]), float(word.attrib["yMax"]) + if 65 <= y0 and y1 <= height - 65: + if x0 < 65 or x1 > width - 80: + raise SmokeFailure(f"text escapes the reading grid in {pdf.name}: {word.text!r}") + body_words.append(word.text or "") + text = normalized(" ".join(body_words)) if "{{" in text or "}}" in text: raise SmokeFailure(f"unresolved template variable in {pdf.name}") for marker in markers: @@ -244,6 +258,108 @@ def check_custom_content(binary: Path, env: dict[str, str], pdftotext: str, root raise SmokeFailure("unresolved term did not block a clean render") +def check_long_parties( + binary: Path, + env: dict[str, str], + pdftotext: str, + output_dir: Path, +) -> int: + issuer_legal = ( + "Acme Example International Software Research, Product Design, Systems " + "Engineering and Responsible Innovation Holdings Limited" + ) + client_legal = ( + "Meridian Example Global Technology Advisory, Digital Infrastructure, " + "Commercial Strategy and Sustainable Ventures Limited" + ) + cli_json( + binary, + env, + [ + "issuer", + "add", + "acme-long", + "--name", + "Acme Example", + "--legal-name", + issuer_legal, + "--jurisdiction", + "uk", + "--address", + "100 Example Way\nExample District\nLondon EX1 1AA\nUnited Kingdom", + "--email", + "contracts-and-legal-notices-for-international-projects@acme-long.example", + ], + ) + cli_json( + binary, + env, + [ + "clients", + "add", + "meridian-long", + "--name", + "Meridian Example", + "--legal-name", + client_legal, + "--jurisdiction", + "England and Wales", + "--address", + "200 Sample Avenue\nSample Quarter\nManchester EX2 2BB\nUnited Kingdom", + "--email", + "legal-and-procurement-correspondence@meridian-long.example", + ], + ) + record = cli_json( + binary, + env, + [ + "new", + "--kind", + "consulting", + "--as", + "acme-long", + "--client", + "meridian-long", + "--purpose", + "Evaluate a software partnership", + "--fee", + "fixed:8400:GBP", + "--legal-profile", + "uk", + "--pack", + "standard", + ], + ) + number = record["number"] + markers = ( + issuer_legal, + client_legal, + "Agreement and signatures", + "The parties agree to the terms set out above.", + "Signed by / for", + ) + rendered = 0 + for template in ("folio", "counsel"): + pdf = output_dir / f"{template}-consulting-long-parties.pdf" + cli_json( + binary, + env, + [ + "render", + number, + "--template", + template, + "--final", + "--out", + str(pdf), + ], + ) + inspect_pdf(pdf, pdftotext, markers) + rendered += 1 + return rendered + + def main() -> int: args = parse_args() binary = resolve_binary(args.binary) @@ -299,11 +415,13 @@ def main() -> int: check_us_letter(letter_pdf, pdfinfo) check_custom_content(binary, env, pdftotext, root) rendered += 1 + rendered += check_long_parties(binary, env, pdftotext, output_dir) dimension_status = "checked" if pdfinfo else "skipped (pdfinfo unavailable)" print( f"OK: {rendered} PDFs; 10 templates x 7 kinds, 3 pack previews, " - f"1 US Letter ({dimension_status}), 1 long custom document; failure/notes checks passed" + f"1 US Letter ({dimension_status}), 1 long custom document, " + f"2 long-party final documents; failure/notes checks passed" ) return 0 diff --git a/src/commands/template.rs b/src/commands/template.rs index 98b97e0..79c053b 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -1,4 +1,3 @@ -use chrono::Utc; use std::path::PathBuf; use crate::clauses; @@ -171,7 +170,7 @@ fn sample_client() -> Client { } fn sample_contract(kind: &str) -> Contract { - let today = Utc::now().date_naive().format("%Y-%m-%d").to_string(); + let today = "2026-09-01".to_string(); let terms = match kind { "nda" => serde_json::json!({ "mutuality": "mutual", diff --git a/src/render.rs b/src/render.rs index 4d17a6d..ea1eacf 100644 --- a/src/render.rs +++ b/src/render.rs @@ -214,6 +214,18 @@ fn term_text(c: &Contract) -> String { } fn term_short(c: &Contract) -> String { + if c.kind == "loan" { + return serde_json::from_str::(&c.terms_json) + .ok() + .and_then(|terms| { + terms + .get("repayment_date") + .and_then(|v| v.as_str()) + .map(str::to_owned) + }) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "See repayment clause".into()); + } if let Some(end) = &c.end_date { // Compact date — use "%-d %b %Y" so "1 Jul 2026" not "1 July 2026" NaiveDate::parse_from_str(end, "%Y-%m-%d") @@ -281,7 +293,7 @@ fn ip_assignment_text(terms: &serde_json::Value, our: &str, their: &str) -> Stri _ => format!("The {our} retains ownership of the Deliverables. On payment of the applicable fees, the {their} receives a non-exclusive, perpetual, worldwide, royalty-free licence to use and adapt the Deliverables for its internal business purposes, and to allow service providers to do so on its behalf. Resale, sublicensing for third-party use and public distribution require an express written licence."), }; format!( - "{disposition}\n\nEach party retains its pre-existing or independently developed tools, libraries, methods and know-how (‘Background IP’). On payment, the {our} grants the {their} a non-exclusive, perpetual, worldwide, royalty-free licence to use, reproduce and adapt its Background IP incorporated into Deliverables, and to permit its customers and service providers to use it, solely as needed for the agreed use of those Deliverables. Third-party materials remain subject to disclosed third-party licences. Moral rights are waived only to the extent lawfully permitted and expressly agreed in writing by the relevant rights holder; otherwise necessary consents shall be obtained." + "{disposition}\n\nEach party retains its pre-existing or independently developed tools, libraries, methods and know-how (‘Background IP’). On payment, the {our} grants the {their} a non-exclusive, perpetual, worldwide, royalty-free licence to use, reproduce and adapt its Background IP incorporated into Deliverables, and to permit its customers and service providers to use it, solely as needed for the agreed use of those Deliverables. Third-party materials remain subject to disclosed third-party licences.\n\nMoral rights are waived only to the extent lawfully permitted and expressly agreed in writing by the relevant rights holder; otherwise necessary consents shall be obtained." ) } diff --git a/typst/shared/contract.typ b/typst/shared/contract.typ index 41ce01e..8e8e1f7 100644 --- a/typst/shared/contract.typ +++ b/typst/shared/contract.typ @@ -112,31 +112,56 @@ // Parse block structure without eval. Continuation lines in lists must never // disappear: legal text cannot be dropped because of its line wrapping. +// Keep a short terminal word (for example "and") with its predecessor. +// The non-breaking space changes only line-breaking, never contract wording. +#let keep-list-tail(s) = { + let words = s.split(" ") + if words.len() > 1 and words.last().len() <= 4 { + let last = words.pop() + let previous = words.pop() + words.push(previous + "\u{00a0}" + last) + } + words.join(" ") +} + #let render-markdown(md) = { - let groups = md.replace("\r\n", "\n").split("\n\n") - for group in groups { + let groups = md.replace("\r\n", "\n").split("\n\n").filter(g => g.trim() != "") + for (index, group) in groups.enumerate() { let kind = "paragraph" let items = () let prose = () - let flush(kind, items, prose) = { + let flush(kind, items, prose, sticky: false) = { if items.len() > 0 { - if kind == "bullet" { list(..items) } else { enum(..items) } + let bodies = items.map(item => { + let body = keep-list-tail(item) + // Normal items move intact. Arbitrarily long custom items still flow. + if item.len() <= 400 { block(breakable: false, body) } else { body } + }) + if kind == "bullet" { list(..bodies) } else { enum(..bodies) } + } + if prose.len() > 0 { + let paragraph = prose.join(" ") + block(sticky: sticky, breakable: paragraph.len() > 400, par(paragraph)) } - if prose.len() > 0 { par(prose.join(" ")) } } for line in group.split("\n") { let line = line.trim() if line == "" { continue } let next = if line.starts-with("- ") { "bullet" } else if starts-with-number(line) { "number" } else { "paragraph" } if next != "paragraph" { - if kind != next { flush(kind, items, prose); items = (); prose = () } + if kind != next { + flush(kind, items, prose, sticky: prose.len() > 0) + items = (); prose = () + } kind = next items.push(if next == "bullet" { line.slice(2) } else { strip-num-prefix(line) }) } else if items.len() > 0 { items.at(items.len() - 1) += " " + line } else { prose.push(line) } } - flush(kind, items, prose) + let next-group = if index + 1 < groups.len() { groups.at(index + 1).trim() } else { "" } + let before-list = next-group.starts-with("- ") or starts-with-number(next-group) + flush(kind, items, prose, sticky: before-list) } } @@ -344,40 +369,20 @@ // avoiding double-stamping the same info top and bottom of the page. #let compact-strip(theme) = { - let mute = th(theme, "mute", rgb("#666666")) - pad(top: mm-sp.s, bottom: 0mm)[ - #grid( - columns: (1fr, auto), - align: (left + horizon, right + horizon), - context fit-size( - (8pt, 7.5pt, 7pt), - 160mm, - s => text(size: s, fill: mute, tracking: 0.3pt)[#upper(data.kind-label) · No. #data.number], - ), - [], - ) - #v(sp.xs) - #hairline(theme) + pad(left: 8mm, top: 4mm)[ + #set text(font: theme.body-font, size: 8.5pt, fill: theme.mute) + #grid(columns: (1fr, auto), column-gutter: 5mm, + [#data.kind-label], [#data.number]) ] } -// ─── Pagination footer ──────────────────────────────────────────────────── - +// Running furniture is readable at print size and shares the body axis. #let pagination-footer(theme) = { - let mute = th(theme, "mute", rgb("#666666")) - pad(top: 0mm, bottom: mm-sp.s)[ - #hairline(theme) - #v(sp.xs) - #grid( - columns: (1fr, auto), - align: (left + horizon, right + horizon), - // Internal reference code lives only in the footer, at small size, - // so it doesn't intrude on the body. Useful for filing, invisible - // on a casual read. - text(size: 6.5pt, fill: mute, tracking: 0.4pt)[Ref. #data.number], - context text(size: 6.5pt, fill: mute, tracking: 0.4pt)[ - Page #here().page() of #counter(page).final().first() - ], + pad(left: 8mm, bottom: 4mm)[ + #set text(font: theme.body-font, size: 8.5pt, fill: theme.mute) + #grid(columns: (1fr, auto), column-gutter: 5mm, + [#data.number], + context [#here().page() / #counter(page).final().first()], ) ] } diff --git a/typst/shared/modern.typ b/typst/shared/modern.typ index c702730..4f90f31 100644 --- a/typst/shared/modern.typ +++ b/typst/shared/modern.typ @@ -1,68 +1,128 @@ -#import "contract.typ": data, page-shell, hairline, render-markdown, signature-block, th +#import "contract.typ": data, page-shell, hairline, render-markdown, th + +// One reading grid and type scale for the entire collection. Template themes +// supply font character and colour; they cannot change the document geometry. +#let gutter = 8mm +#let quiet(theme, body) = text(size: 9.5pt, fill: theme.mute, body) + +#let party-details(party, theme) = { + set par(leading: 4pt, spacing: 7pt) + quiet(theme, party.role-label) + v(5pt) + text(weight: 600)[#if party.legal-name != none { party.legal-name } else { party.display-name }] + v(5pt) + text(size: 9.5pt)[ + #party.address.join(", ") + #if party.company-no != none { linebreak(); [Company no. #party.company-no] } + #if party.email != none { linebreak(); party.email } + ] +} + +#let execution(theme) = { + let sig = data.signature + let field(label, value) = { + grid(columns: (13mm, 1fr), column-gutter: 2mm, align: bottom, + quiet(theme, label), + if value != none and value != "" { + text(size: 10.5pt, value) + } else { + block(height: 9mm, width: 100%)[ + #place(bottom, line(length: 100%, stroke: 0.35pt + theme.hair)) + ] + }, + ) + } + let party(prefix) = { + let get(key) = sig.at(prefix + key, default: none) + set par(leading: 4pt, spacing: 7pt) + quiet(theme, "Signed by / for") + v(5pt) + text(weight: 600)[#get("-name")] + v(15mm) + line(length: 100%, stroke: 0.45pt + theme.mute) + v(4pt) + quiet(theme, "Signature") + v(5pt) + field("Name", get("-signer-name")) + field("Title", get("-signer-title")) + field("Date", get("-signer-date")) + } + block(breakable: false, above: 24pt)[ + #text(size: th(theme, "body-size", 11.25pt) + 1pt, weight: 600)[Agreement and signatures] + #v(8pt) + #text(size: 10.5pt)[The parties agree to the terms set out above.] + #v(18pt) + #grid(columns: (1fr, 1fr), column-gutter: 12mm, party("our"), party("their")) + ] +} -// A document-first system: real headings, selectable text, flowing clauses, -// and an execution block that moves intact when space runs out. #let modern-contract(theme, character: "folio") = { - set text(font: theme.body-font, size: 10pt, fill: theme.ink, lang: "en", hyphenate: false, number-type: "lining") - set par(leading: 4.5pt, spacing: 7pt, justify: false) - set list(indent: 12pt, body-indent: 5pt, spacing: 3pt) - set enum(indent: 12pt, body-indent: 5pt, spacing: 3pt) + let body-size = th(theme, "body-size", 11.25pt) + set text(font: theme.body-font, size: body-size, fill: theme.ink, + lang: "en", hyphenate: false, number-type: "lining") + // Both values measure ink-to-ink gaps. Six extra points between paragraphs + // create separation without the contradictory use of first-line indents. + let line-gap = if theme.body-font == "Literata" { 6.5pt } else { 5.5pt } + set par(leading: line-gap, spacing: line-gap + 6pt, justify: false, first-line-indent: 0pt) + set list(indent: 1mm, body-indent: 3mm, spacing: 8pt) + set enum(indent: 1mm, body-indent: 3mm, spacing: 8pt) set heading(numbering: none) show heading.where(level: 1): it => block(above: 0pt, below: 0pt, sticky: true)[ - #text(font: theme.display-font, size: if character == "atelier" { 35pt } else if character == "counsel" { 27pt } else { 31pt }, weight: 500, tracking: -0.6pt)[#it.body] + #set par(leading: 5pt) + #text(font: theme.display-font, size: 27pt, + weight: th(theme, "title-weight", 500), tracking: -0.35pt)[#it.body] ] - show heading.where(level: 2): it => block(above: 12pt, below: 4pt, sticky: true)[ - #text(font: theme.display-font, size: 10.3pt, weight: 600)[#it.body] + show heading.where(level: 2): it => block(above: 19pt, below: 7pt, sticky: true)[ + #text(size: body-size + 1pt, weight: 600)[#it.body] ] - page-shell(theme, [ - #if data.logo != none { image(data.logo, width: 22mm, height: 10mm, fit: "contain"); v(7mm) } - #heading(level: 1, data.kind-label) - #if data.subtitle != none { - v(4mm) - text(font: theme.body-font, size: 12pt, fill: theme.mute)[#data.subtitle] + // Fixed margins also apply to Letter. The number gutter is outside the + // reading column, so one- and two-digit clauses share an identical axis. + let theme = theme + (margin: (top: 25mm, bottom: 25mm, left: 30mm, right: 30mm)) + page-shell(theme, pad(left: gutter)[ + #if data.logo != none { + image(data.logo, width: 25mm, height: 11mm, fit: "contain") + v(16pt) } - #v(7mm) - #text(size: 9pt, fill: theme.mute)[Effective #data.effective-date-display] - #v(5mm) - #hairline(theme, weight: 0.6pt) - #v(4mm) - #for (i, party) in (data.our-party, data.their-party).enumerate() { - block(breakable: true, above: 3pt, below: 6pt)[ - #grid(columns: (17mm, 1fr), column-gutter: 4mm, - text(size: 8.5pt, fill: theme.mute)[#party.role-label], - [ - #text(weight: 600)[#if party.legal-name != none { party.legal-name } else { party.display-name }] - #linebreak() - #text(size: 9pt)[#party.address.join(", ")] - #if party.company-no != none { linebreak(); text(size: 8.5pt, fill: theme.mute)[Company no. #party.company-no] } - #if party.email != none { linebreak(); text(size: 8.5pt, fill: theme.mute)[#party.email] } - ] - ) - ] + #heading(level: 1)[ + #show regex("Non-(Disclosure|Circumvention)"): it => box(it) + #data.kind-label + ] + #if data.subtitle != none { + v(10pt) + text(size: 12pt, fill: theme.mute)[#data.subtitle] } - #v(3mm) - #hairline(theme) - #v(4mm) - #let terms = (("Term", data.term-short), ("Governing law", data.governing-law)) + #v(23pt) + #grid(columns: (1fr, 1fr), column-gutter: 12mm, + party-details(data.our-party, theme), party-details(data.their-party, theme)) + #v(17pt) + #hairline(theme, weight: 0.4pt) + #v(12pt) + #let terms = (("Effective date", data.effective-date-display), + (if data.kind == "loan" { "Repayment" } else { "Term" }, data.term-short), ("Governing law", data.governing-law)) #if data.fee-short != none { terms += (("Fees", data.fee-short),) } - #for (label, value) in terms { - grid(columns: (28mm, 1fr), column-gutter: 3mm, - text(size: 8.5pt, fill: theme.mute)[#label], text(size: 9pt)[#value]) - v(2pt) - } - #v(4mm) - #hairline(theme) - #v(2mm) - #for clause in data.clauses { - heading(level: 2)[#clause.number. #clause.heading] - render-markdown(clause.body) - } - #block(breakable: false, above: 10mm)[ - #text(font: theme.display-font, size: 13pt, weight: 500)[Agreement and signatures] - #v(3mm) - #text(size: 9pt)[The parties agree to the terms set out above.] - #v(5mm) - #signature-block(data.signature, theme) + #grid(columns: (29mm, 1fr), column-gutter: 4mm, row-gutter: 5pt, + ..terms.map(((label, value)) => (quiet(theme, label), text(size: 10.5pt, value))).flatten()) + #v(12pt) + #hairline(theme, weight: 0.4pt) + #let clause-content(clause) = [ + #heading(level: 2)[ + #place(top + left, dx: -gutter, + box(width: 6mm, align(right, text(fill: theme.accent)[#clause.number.]))) + #clause.heading + ] + #render-markdown(clause.body) ] + #for (index, clause) in data.clauses.enumerate() { + if index == data.clauses.len() - 1 { + // Keep a modest closing clause with execution when the measured + // content fits comfortably on a page. Long clauses remain flowing. + layout(size => { + let closing = [#clause-content(clause)#execution(theme)] + let height = measure(closing, width: size.width).height + block(breakable: height > 180mm, closing) + }) + } else { clause-content(clause) } + } + #if data.clauses.len() == 0 { execution(theme) } ]) } diff --git a/typst/templates/atelier.typ b/typst/templates/atelier.typ index 53731cb..ef58dc0 100644 --- a/typst/templates/atelier.typ +++ b/typst/templates/atelier.typ @@ -1,12 +1,12 @@ -//! description: Quiet design-studio agreements with expressive type, warm paper and precise spacing. +//! description: Quiet design-studio agreements in Newsreader on warm white paper. //! mood: warm, editorial, considered //! tags: design, creative, studio, branding, architecture, warm -//! fonts: Newsreader 16pt, Libre Franklin (bundled OFL) +//! fonts: Newsreader 16pt (bundled OFL) //! paper: A4 / warm white #import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#302B27"), paper: rgb("#FCFAF6"), accent: rgb("#765744"), mute: rgb("#655D55"), hair: rgb("#D8CFC4"), watermark: rgb("#EEE8E0"), - display-font: "Newsreader 16pt", body-font: "Libre Franklin", label-style: "upper", - margin: (top: 23mm, bottom: 25mm, left: 27mm, right: 27mm), + ink: rgb("#302B27"), paper: rgb("#FCFAF6"), accent: rgb("#765744"), + mute: rgb("#655D55"), hair: rgb("#D8CFC4"), watermark: rgb("#EEECE8"), + display-font: "Newsreader 16pt", body-font: "Newsreader 16pt", body-size: 12pt, title-weight: 500, ) #modern-contract(theme, character: "atelier") diff --git a/typst/templates/basel.typ b/typst/templates/basel.typ index db844b1..be5a8dc 100644 --- a/typst/templates/basel.typ +++ b/typst/templates/basel.typ @@ -1,157 +1,12 @@ -//! description: Swiss brutalist instrument — full-width black bar, 42mm apparatus rail, oversized clause numerals, ragged Helvetica body. Severe and architectural. +//! description: Architectural Swiss typography in Archivo with a firm title and precise hanging numerals. //! mood: severe, modernist, architectural //! tags: swiss, brutalist, grid, monochrome, rail, severe, modernist, architectural -//! fonts: Helvetica Neue (system), Archivo (embedded OFL) +//! fonts: Archivo (bundled OFL) //! paper: white -// ═══════════════════════════════════════════════════════════════════════════ -// basel — Swiss brutalist asymmetric grid. -// A 42mm left rail owns ALL apparatus: DATED, the parties summary, and -// TERM/LAW/FEE stacked as Archivo marginalia. The body runs ragged in -// Helvetica Neue on the right. Clause numerals sit oversized in the rail. -// One full-width black bar at the top; everything else is hairlines. -// ═══════════════════════════════════════════════════════════════════════════ - -#import "../shared/contract.typ": data, lbl, hairline, fit-size, parties-prose-block, signature-block, page-shell, render-markdown, section-label, sp, mm-sp - +#import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#0B0B0B"), - paper: rgb("#FFFFFF"), - accent: rgb("#0B0B0B"), - mute: rgb("#5A5A5A"), - hair: rgb("#C9C9C9"), - watermark: rgb("#DCDCDC"), - display-font: ("Archivo", "Helvetica Neue", "Helvetica", "Arial"), - body-font: ("Helvetica Neue", "Archivo", "Helvetica", "Arial"), - mono-font: ("Menlo", "DejaVu Sans Mono"), - label-style: "upper", - margin: (top: 18mm, bottom: 22mm, left: 18mm, right: 18mm), + ink: rgb("#191919"), paper: rgb("#FFFFFF"), accent: rgb("#191919"), + mute: rgb("#555555"), hair: rgb("#C9C9C9"), watermark: rgb("#EEECE8"), + display-font: "Archivo", body-font: "Archivo", body-size: 11.25pt, title-weight: 600, ) - -#show: body => page-shell(theme, body) - -#set text( - font: theme.body-font, - size: 9.4pt, - fill: theme.ink, - lang: "en", - number-type: "lining", - number-width: "tabular", - hyphenate: false, -) -#set par(leading: 5.6pt, spacing: 5.6pt, justify: false) - -// The one grid geometry everything obeys: 42mm rail · 8mm gutter · body. -#let rail-row(rail, body) = grid( - columns: (42mm, 1fr), - column-gutter: 8mm, - align: (left + top, left + top), - rail, body, -) - -#let rail-label(txt) = text( - font: theme.display-font, size: 6.5pt, weight: 600, - tracking: 1.4pt, fill: theme.mute, -)[#upper(txt)] - -#let rail-value(txt) = text( - font: theme.display-font, size: 8.6pt, weight: 500, fill: theme.ink, -)[#txt] - -#let rail-rule = line(length: 100%, stroke: 0.5pt + theme.ink) - -// ─── FULL-WIDTH BLACK BAR ── -#rect(width: 100%, height: 7mm, fill: theme.ink) - -#v(mm-sp.m) - -// ─── HEAD ROW: apparatus rail · title + parties ── -#rail-row( - [ - // DATED - #rail-rule - #v(sp.s) - #rail-label("Dated") - #v(sp.xxs) - #rail-value(data.effective-date-display) - #v(sp.m) - // PARTIES summary — display names only; the instrument text carries - // the full legal recitals on the right. - #rail-rule - #v(sp.s) - #rail-label(data.signature.our-label) - #v(sp.xxs) - #rail-value(data.our-party.display-name) - #v(sp.s) - #rail-label(data.signature.their-label) - #v(sp.xxs) - #rail-value(data.their-party.display-name) - #v(sp.m) - // TERM / LAW / FEE marginalia - #rail-rule - #v(sp.s) - #rail-label("Term") - #v(sp.xxs) - #rail-value(data.term-short) - #v(sp.s) - #rail-label("Governing law") - #v(sp.xxs) - #rail-value(data.governing-law) - #if data.fee-short != none [ - #v(sp.s) - #rail-label("Fee") - #v(sp.xxs) - #rail-value(data.fee-short) - ] - ], - [ - // Title — Helvetica, heavy, tight. - #fit-size( - (25pt, 23pt, 21pt, 19pt, 17pt), - 120mm, - s => text(font: theme.body-font, size: s, weight: 700, tracking: -0.3pt)[#data.kind-label], - ) - #if data.subtitle != none [ - #v(3pt) - #text(font: theme.body-font, size: 11.5pt, weight: 400, fill: theme.mute)[#data.subtitle] - ] - #v(mm-sp.m) - #text(font: theme.display-font, size: 7pt, weight: 600, tracking: 1.4pt, fill: theme.mute)[PARTIES] - #v(2pt) - #parties-prose-block(data.parties-prose, theme) - ], -) - -#v(mm-sp.m) - -// AGREED TERMS sits in its own row so it hugs clause 1 even when the -// apparatus rail is taller than the head-row body. -#rail-row([], text(font: theme.display-font, size: 7pt, weight: 600, tracking: 1.4pt, fill: theme.mute)[AGREED TERMS]) - -#v(mm-sp.xs) - -// ─── CLAUSES — oversized numeral in the rail, body right ── -#for clause in data.clauses { - block(breakable: true, spacing: mm-sp.m, rail-row( - align(right, text( - font: theme.display-font, size: 21pt, weight: 600, - tracking: -0.3pt, fill: theme.ink, - )[#clause.number]), - [ - #v(2.4pt) // optically align heading baseline with the numeral - #text(font: theme.body-font, size: 10.5pt, weight: 700)[#clause.heading] - #v(2pt) - #render-markdown(clause.body) - ], - )) -} - -// ─── EXECUTION ── -#v(mm-sp.l) -#block(breakable: false, rail-row( - [ - #rail-rule - #v(sp.s) - #rail-label("Execution") - ], - signature-block(data.signature, theme), -)) +#modern-contract(theme, character: "basel") diff --git a/typst/templates/chancery.typ b/typst/templates/chancery.typ index bd22baa..1199995 100644 --- a/typst/templates/chancery.typ +++ b/typst/templates/chancery.typ @@ -1,146 +1,12 @@ -//! description: Engraved deed — double hairline frame at the masthead, letterspaced Cormorant caps, EB Garamond body with true small caps, witness-style execution. For agreements that should feel notarised. +//! description: Classic agreements in EB Garamond with generous leading and a clear execution block. //! mood: formal, engraved, ceremonial -//! tags: engraved, formal, deed, garamond, small-caps, centred, classic, witness, chancery -//! fonts: Cormorant Garamond, EB Garamond (embedded OFL) +//! tags: engraved, formal, garamond, small-caps, classic, chancery +//! fonts: EB Garamond (bundled OFL) //! paper: ivory -// ═══════════════════════════════════════════════════════════════════════════ -// chancery — engraved deed on a centred axis. -// Masthead is a double hairline frame holding the kind-label in letterspaced -// Cormorant Garamond caps. Body is EB Garamond, justified, with REAL small -// caps (smcp) doing the section labels. Execution is witness-style. -// ═══════════════════════════════════════════════════════════════════════════ - -#import "../shared/contract.typ": data, lbl, hairline, fit-size, parties-prose-block, signature-block, page-shell, render-markdown, section-label, sp, mm-sp - +#import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#211D18"), - paper: rgb("#FBF8F1"), - accent: rgb("#211D18"), - mute: rgb("#756E62"), - hair: rgb("#CFC5B2"), - watermark: rgb("#E3DAC6"), - display-font: ("Cormorant Garamond", "EB Garamond", "Georgia"), - body-font: ("EB Garamond", "Georgia", "Times New Roman"), - mono-font: ("Menlo", "DejaVu Sans Mono"), - label-style: "smallcaps", - margin: (top: 24mm, bottom: 24mm, left: 28mm, right: 28mm), + ink: rgb("#2D2B26"), paper: rgb("#FDFBF6"), accent: rgb("#494338"), + mute: rgb("#655F53"), hair: rgb("#D7D0C3"), watermark: rgb("#EEECE8"), + display-font: "EB Garamond", body-font: "EB Garamond", body-size: 12.5pt, title-weight: 500, ) - -#show: body => page-shell(theme, body) - -#set text( - font: theme.body-font, - size: 10.6pt, - fill: theme.ink, - lang: "en", - number-type: "old-style", - hyphenate: true, -) -#set par(leading: 6.8pt, spacing: 6.8pt, justify: true) - -// Real small caps — EB Garamond carries a proper smcp feature. -#let sc-label(txt, size: 10.5pt, tracking: 1.8pt, fill: theme.ink) = text( - font: theme.body-font, size: size, tracking: tracking, fill: fill, -)[#smallcaps(lower(txt))] - -// ─── MASTHEAD: double hairline frame ── -#align(center)[ - #rect( - width: 100%, - stroke: 0.5pt + theme.ink, - inset: 1.8mm, - rect( - width: 100%, - stroke: 0.5pt + theme.ink, - inset: (x: 8mm, top: 7mm, bottom: 7.5mm), - align(center)[ - #fit-size( - (21pt, 19pt, 17pt, 15pt, 13pt), - 120mm, - s => text(font: theme.display-font, size: s, weight: 600, tracking: 3.5pt)[#upper(data.kind-label)], - ) - #if data.subtitle != none [ - #v(3.5mm) - #line(length: 24mm, stroke: 0.4pt + theme.hair) - #v(3mm) - #fit-size( - (12.5pt, 11.5pt, 10.5pt), - 120mm, - s => text(font: theme.body-font, size: s, style: "italic", fill: theme.mute)[#data.subtitle], - ) - ] - ], - ), - ) -] - -#v(mm-sp.m) - -// ─── DATED (centred, ceremonial) ── -#align(center, text(size: 10.6pt)[#smallcaps[dated] #h(4pt) #data.effective-date-display]) - -#v(mm-sp.m) - -// ─── PARTIES ── -#align(center, sc-label("This Agreement is made between")) -#v(2pt) -#parties-prose-block(data.parties-prose, theme) - -#v(mm-sp.s) -#align(center, line(length: 24mm, stroke: 0.4pt + theme.hair)) -#v(mm-sp.s) - -// ─── KEY TERMS (centred, small-caps labels) ── -#let cells = (("Term", data.term-short), ("Governing law", data.governing-law)) -#if data.fee-short != none { - cells = cells + (("Fee", data.fee-short),) -} -#align(center, grid( - columns: cells.map(_ => auto), - column-gutter: 14mm, - align: (center + top, center + top, center + top), - ..cells.map(((lbl-t, val)) => [ - #sc-label(lbl-t, size: 8.5pt, tracking: 1.4pt, fill: theme.mute)\ - #v(1pt) - #text(size: 10.2pt)[#val] - ]) -)) - -#v(mm-sp.s) -#align(center, line(length: 24mm, stroke: 0.4pt + theme.hair)) -#v(mm-sp.m) - -// ─── AGREED TERMS ── -#align(center, sc-label("Agreed Terms", tracking: 2.2pt)) -#v(mm-sp.xs) - -// ─── CLAUSES (small-caps headings on the centred axis's left rail) ── -#for clause in data.clauses { - block(breakable: true, spacing: mm-sp.s, [ - #grid( - columns: (9mm, 1fr), - column-gutter: 3.5mm, - align: (right + top, left + top), - text(font: theme.body-font, size: 11pt, weight: 500)[#clause.number.], - [ - #text(font: theme.body-font, size: 11pt, weight: 500)[#smallcaps(lower(clause.heading))] - #v(2pt) - #render-markdown(clause.body) - ], - ) - ]) -} - -// ─── EXECUTION (witness-style) ── -#v(mm-sp.l) -#block(breakable: false, [ - #align(center, line(length: 24mm, stroke: 0.4pt + theme.hair)) - #v(mm-sp.s) - #align(center, sc-label("In witness whereof", tracking: 2.2pt)) - #v(2pt) - #align(center, text(size: 10.2pt, style: "italic", fill: theme.mute)[ - the parties have executed this agreement on the date first written above. - ]) - #v(mm-sp.s) - #signature-block(data.signature, theme) -]) +#modern-contract(theme, character: "chancery") diff --git a/typst/templates/counsel.typ b/typst/templates/counsel.typ index b095ceb..7e6a393 100644 --- a/typst/templates/counsel.typ +++ b/typst/templates/counsel.typ @@ -1,12 +1,12 @@ -//! description: Restrained legal typography with a literary serif and clear numbered clauses. +//! description: Restrained legal typography in Literata with hanging clause numbers and a calm reading rhythm. //! mood: formal, assured, legible //! tags: legal, formal, lawyer, agreement, serif, classic -//! fonts: Literata, Libre Franklin (bundled OFL) +//! fonts: Literata (bundled OFL) //! paper: A4 / white #import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#252525"), paper: white, accent: rgb("#252525"), mute: rgb("#595959"), hair: rgb("#CECECE"), watermark: rgb("#EEEEEE"), - display-font: "Literata", body-font: "Literata", label-style: "upper", - margin: (top: 25mm, bottom: 25mm, left: 28mm, right: 28mm), + ink: rgb("#252525"), paper: rgb("#FFFFFF"), accent: rgb("#252525"), + mute: rgb("#595959"), hair: rgb("#CECECE"), watermark: rgb("#EEECE8"), + display-font: "Literata", body-font: "Literata", body-size: 11.25pt, title-weight: 500, ) #modern-contract(theme, character: "counsel") diff --git a/typst/templates/editorial.typ b/typst/templates/editorial.typ index 314c18c..1a40c5f 100644 --- a/typst/templates/editorial.typ +++ b/typst/templates/editorial.typ @@ -1,119 +1,12 @@ -//! description: Formal serif on soft cream — centred axis, reads like a deed. Classic and literary. +//! description: Literary agreements in Literata on soft ivory with a consistent left-aligned reading grid. //! mood: formal, classic, literary -//! tags: serif, classic, formal, traditional, deed, elegant, literary, editorial, georgia -//! fonts: Georgia (system) +//! tags: serif, classic, formal, traditional, elegant, literary, editorial +//! fonts: Literata (bundled OFL) //! paper: cream -// ═══════════════════════════════════════════════════════════════════════════ -// editorial — Formal serif. Centred title. Roman semibold (italic only on -// the project subtitle when present). Reads like a deed. -// ═══════════════════════════════════════════════════════════════════════════ - -#import "../shared/contract.typ": data, lbl, hairline, fit-size, parties-prose-block, signature-block, page-shell, render-markdown, section-label, sp, mm-sp - +#import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#1D1A16"), - paper: rgb("#F8F4EA"), - accent: rgb("#2A2A2A"), - mute: rgb("#7A7267"), - hair: rgb("#D6CBB9"), - watermark: rgb("#E5DCC9"), - display-font: ("Georgia", "Newsreader 16pt", "EB Garamond", "Times New Roman"), - body-font: ("Georgia", "Newsreader 16pt", "EB Garamond", "Times New Roman"), - mono-font: ("Menlo", "DejaVu Sans Mono"), - label-style: "upper", - margin: (top: 28mm, bottom: 26mm, left: 30mm, right: 30mm), + ink: rgb("#302D28"), paper: rgb("#FCFAF5"), accent: rgb("#4E473D"), + mute: rgb("#645F55"), hair: rgb("#D7D0C4"), watermark: rgb("#EEECE8"), + display-font: "Literata", body-font: "Literata", body-size: 11.25pt, title-weight: 500, ) - -#show: body => page-shell(theme, body) - -#set text( - font: theme.body-font, - size: 10.2pt, - fill: theme.ink, - lang: "en", - number-type: "lining", - hyphenate: false, -) -#set par(leading: 6.6pt, spacing: 6.6pt, justify: true, first-line-indent: 0pt) - -// ─── HERO ── -#align(center)[ - #fit-size( - (26pt, 24pt, 22pt, 20pt), - 132mm, - s => text(font: theme.display-font, size: s, weight: 600)[#data.kind-label], - ) - #if data.subtitle != none [ - #v(8pt) - #fit-size( - (13pt, 12pt, 11pt), - 132mm, - s => text(font: theme.display-font, size: s, weight: 400, style: "italic", fill: theme.mute)[#data.subtitle], - ) - ] - #v(10pt) - #line(length: 28mm, stroke: 0.4pt + theme.hair) -] - -#v(mm-sp.m) - -// ─── DATED ── -#text(size: 10pt, style: "italic")[Dated #data.effective-date-display.] - -#v(mm-sp.s) - -// ─── PARTIES ── -#section-label(theme, "Parties", size: 10pt, tracking: 0.8pt) -#v(2pt) -#parties-prose-block(data.parties-prose, theme) - -#v(mm-sp.s) -#align(center, line(length: 28mm, stroke: 0.4pt + theme.hair)) -#v(mm-sp.s) - -// ─── KEY TERMS (italic labels) ── -#let cells = (("Term", data.term-short), ("Governing law", data.governing-law)) -#if data.fee-short != none { - cells = cells + (("Fee", data.fee-short),) -} -#grid( - columns: cells.map(_ => 1fr), - column-gutter: 8mm, - align: (left + horizon, left + horizon, left + horizon), - ..cells.map(((lbl-t, val)) => [ - #text(size: 7.5pt, fill: theme.mute, tracking: 1pt, style: "italic")[#upper(lbl-t)]\ - #v(1pt) - #text(size: 9.8pt)[#val] - ]) -) - -#v(mm-sp.s) -#align(center, line(length: 28mm, stroke: 0.4pt + theme.hair)) -#v(mm-sp.m) - -// ─── AGREED TERMS ── -#section-label(theme, "Agreed terms", size: 10pt, tracking: 0.8pt) -#v(mm-sp.xs) - -// ─── CLAUSES ── -#for clause in data.clauses { - block(breakable: true, spacing: mm-sp.s, [ - #grid( - columns: (8mm, 1fr), - column-gutter: 4mm, - align: (right + top, left + top), - text(font: theme.display-font, size: 11pt, weight: 500)[#clause.number.], - [ - #text(font: theme.display-font, size: 11pt, weight: 600)[#clause.heading] - #v(2pt) - #render-markdown(clause.body) - ], - ) - ]) -} - -// ─── SIGNATURE ── -#v(mm-sp.l) -#align(center, text(size: 9pt, tracking: 2pt, fill: theme.mute, style: "italic")[#upper("In witness whereof")]) -#v(mm-sp.s) -#signature-block(data.signature, theme) +#modern-contract(theme, character: "editorial") diff --git a/typst/templates/folio.typ b/typst/templates/folio.typ index 16e1497..7a15ef7 100644 --- a/typst/templates/folio.typ +++ b/typst/templates/folio.typ @@ -1,12 +1,12 @@ -//! description: Precise technology and startup agreements with generous type and clean white pages. +//! description: Precise technology and startup agreements with a clear sans serif and generous reading space. //! mood: precise, calm, contemporary //! tags: technology, software, startup, modern, clean, white -//! fonts: Archivo, Libre Franklin (bundled OFL) +//! fonts: Libre Franklin (bundled OFL) //! paper: A4 / white #import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#202722"), paper: white, accent: rgb("#33483E"), mute: rgb("#58615B"), hair: rgb("#CCD3CE"), watermark: rgb("#EDF0ED"), - display-font: "Archivo", body-font: "Libre Franklin", label-style: "upper", - margin: (top: 22mm, bottom: 24mm, left: 26mm, right: 26mm), + ink: rgb("#202722"), paper: rgb("#FFFFFF"), accent: rgb("#33483E"), + mute: rgb("#58615B"), hair: rgb("#CCD3CE"), watermark: rgb("#EEECE8"), + display-font: "Libre Franklin", body-font: "Libre Franklin", body-size: 11.25pt, title-weight: 500, ) -#modern-contract(theme) +#modern-contract(theme, character: "folio") diff --git a/typst/templates/gazette.typ b/typst/templates/gazette.typ index c9d6e18..2d93351 100644 --- a/typst/templates/gazette.typ +++ b/typst/templates/gazette.typ @@ -1,142 +1,12 @@ -//! description: Front-page broadsheet — Fraunces Black masthead over thick-and-thin rules, newspaper dateline folio, ragged Newsreader body. A contract set like page one. +//! description: An expressive Fraunces masthead with calm Newsreader text and carefully spaced clauses. //! mood: literary, statement, editorial //! tags: magazine, masthead, editorial, serif, white, ragged, statement, broadsheet, newspaper -//! fonts: Fraunces 72pt, Newsreader 16pt, Libre Franklin (embedded OFL) +//! fonts: Newsreader 16pt, Fraunces 72pt (bundled OFL) //! paper: white -// ═══════════════════════════════════════════════════════════════════════════ -// gazette — magazine masthead / front-page broadsheet. -// The kind-label is set as a newspaper masthead in Fraunces Black across -// the full measure, over a thick-over-thin double rule. A dateline "folio" -// strip (No. · Dated · Governing law) runs beneath in Libre Franklin caps. -// Body is Newsreader, ragged right — literary, no drop caps, no tricks. -// ═══════════════════════════════════════════════════════════════════════════ - -#import "../shared/contract.typ": data, lbl, hairline, fit-size, parties-prose-block, signature-block, page-shell, render-markdown, section-label, sp, mm-sp - +#import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#141414"), - paper: rgb("#FFFFFF"), - accent: rgb("#141414"), - mute: rgb("#5F5F5F"), - hair: rgb("#D9D9D9"), - watermark: rgb("#DCDCDC"), - display-font: ("Libre Franklin", "Helvetica Neue", "Helvetica", "Arial"), - body-font: ("Newsreader 16pt", "Georgia", "Times New Roman"), - masthead-font: ("Fraunces 72pt", "Georgia", "Times New Roman"), - mono-font: ("Menlo", "DejaVu Sans Mono"), - label-style: "upper", - margin: (top: 20mm, bottom: 22mm, left: 22mm, right: 22mm), + ink: rgb("#252525"), paper: rgb("#FFFFFF"), accent: rgb("#252525"), + mute: rgb("#595959"), hair: rgb("#CCCCCC"), watermark: rgb("#EEECE8"), + display-font: "Fraunces 72pt", body-font: "Newsreader 16pt", body-size: 12pt, title-weight: 600, ) - -#show: body => page-shell(theme, body) - -#set text( - font: theme.body-font, - size: 9.8pt, - fill: theme.ink, - lang: "en", - number-type: "lining", - hyphenate: false, -) -#set par(leading: 6pt, spacing: 6pt, justify: false) - -// ─── MASTHEAD (Fraunces Black, full measure) ── -#align(center)[ - #fit-size( - (34pt, 31pt, 28pt, 25pt, 22pt, 19pt), - 162mm, - s => text(font: theme.masthead-font, size: s, weight: 900, tracking: -0.3pt)[#data.kind-label], - ) -] - -#v(2.4mm) -// Thick-over-thin double rule — the broadsheet signature. -#line(length: 100%, stroke: 2.2pt + theme.ink) -#v(1.1mm) -#line(length: 100%, stroke: 0.5pt + theme.ink) -#v(1.6mm) - -// ─── DATELINE FOLIO (No. · Dated · Governing law) ── -#grid( - columns: (1fr, auto, 1fr), - align: (left + horizon, center + horizon, right + horizon), - text(font: theme.display-font, size: 7pt, weight: 600, tracking: 1pt, fill: theme.ink)[NO. #upper(data.number)], - text(font: theme.display-font, size: 7pt, weight: 600, tracking: 1pt, fill: theme.ink)[DATED #upper(data.effective-date-display)], - text(font: theme.display-font, size: 7pt, weight: 600, tracking: 1pt, fill: theme.ink)[#upper(data.governing-law) LAW], -) -#v(1.6mm) -#line(length: 100%, stroke: 0.5pt + theme.ink) - -// ─── DECK (project name as the standfirst) ── -#if data.subtitle != none [ - #v(4mm) - #align(center)[ - #fit-size( - (14pt, 13pt, 12pt, 11pt), - 150mm, - s => text(font: theme.body-font, size: s, style: "italic", fill: theme.ink)[#data.subtitle], - ) - ] -] - -#v(mm-sp.m) - -// ─── PARTIES ── -#text(font: theme.display-font, size: 8pt, weight: 600, tracking: 1.4pt)[PARTIES] -#v(2pt) -#parties-prose-block(data.parties-prose, theme) - -#v(mm-sp.s) -#hairline(theme) -#v(mm-sp.s) - -// ─── KEY TERMS ── -#let cells = (("Term", data.term-short), ("Governing law", data.governing-law)) -#if data.fee-short != none { - cells = cells + (("Fee", data.fee-short),) -} -#grid( - columns: cells.map(_ => (100% - 16mm) / 3), - column-gutter: 8mm, - align: (left + top, left + top, left + top), - ..cells.map(((lbl-t, val)) => [ - #text(font: theme.display-font, size: 7pt, fill: theme.mute, weight: 600, tracking: 1.2pt)[#upper(lbl-t)]\ - #v(1pt) - #text(size: 9.8pt)[#val] - ]) -) - -#v(mm-sp.s) -#hairline(theme) -#v(mm-sp.m) - -// ─── AGREED TERMS ── -#text(font: theme.display-font, size: 8pt, weight: 600, tracking: 1.4pt)[AGREED TERMS] -#v(mm-sp.xs) - -// ─── CLAUSES (literary: serif headline, hanging number) ── -#for clause in data.clauses { - block(breakable: true, spacing: mm-sp.s, [ - #grid( - columns: (8mm, 1fr), - column-gutter: 4mm, - align: (right + top, left + top), - text(font: theme.body-font, size: 11pt, weight: 600, fill: theme.mute)[#clause.number.], - [ - #text(font: theme.body-font, size: 11pt, weight: 600)[#clause.heading] - #v(2pt) - #render-markdown(clause.body) - ], - ) - ]) -} - -// ─── SIGNATURE (closing thin-over-thick rule travels with the block) ── -#v(mm-sp.l) -#block(breakable: false, [ - #line(length: 100%, stroke: 0.5pt + theme.ink) - #v(0.9mm) - #line(length: 100%, stroke: 2.2pt + theme.ink) - #v(mm-sp.s) - #signature-block(data.signature, theme) -]) +#modern-contract(theme, character: "gazette") diff --git a/typst/templates/helvetica-nera.typ b/typst/templates/helvetica-nera.typ index 8cb1a9d..ffa3c89 100644 --- a/typst/templates/helvetica-nera.typ +++ b/typst/templates/helvetica-nera.typ @@ -1,118 +1,12 @@ -//! description: Sober Swiss corporate instrument — Helvetica, black on white, hairline rules. The house default. +//! description: Sober Swiss typography in bundled Archivo, black on white. The house default. //! mood: corporate, sober, modern //! tags: swiss, minimal, monochrome, corporate, modern, sans-serif, clean, black-white, helvetica, plain -//! fonts: Helvetica Neue (system) +//! fonts: Archivo (bundled OFL) //! paper: white -// ═══════════════════════════════════════════════════════════════════════════ -// helvetica-nera — Sober corporate/legal instrument. -// Left-aligned kind-label IS the hero title; the project name (if any) -// acts as subtitle in mute. Real-contract conventions: no eyebrow, no -// reference code in body, "DATED" line + numbered (1)/(2) parties prose, -// full-black clauses. -// ═══════════════════════════════════════════════════════════════════════════ - -#import "../shared/contract.typ": data, lbl, hairline, fit-size, parties-prose-block, signature-block, page-shell, render-markdown, section-label, sp, mm-sp - +#import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#111111"), - paper: rgb("#FFFFFF"), - accent: rgb("#111111"), - mute: rgb("#666666"), - hair: rgb("#D7D7D7"), - watermark: rgb("#D8D8D8"), - display-font: ("Helvetica Neue", "Helvetica", "Inter", "Arial"), - body-font: ("Helvetica Neue", "Helvetica", "Inter", "Arial"), - mono-font: ("Menlo", "DejaVu Sans Mono"), - label-style: "upper", - margin: (top: 24mm, bottom: 22mm, left: 24mm, right: 24mm), + ink: rgb("#202020"), paper: rgb("#FFFFFF"), accent: rgb("#202020"), + mute: rgb("#5A5A5A"), hair: rgb("#CCCCCC"), watermark: rgb("#EEECE8"), + display-font: "Archivo", body-font: "Archivo", body-size: 11.25pt, title-weight: 500, ) - -#show: body => page-shell(theme, body) - -#set text( - font: theme.body-font, - size: 9.6pt, - fill: theme.ink, - lang: "en", - number-type: "lining", - number-width: "tabular", - hyphenate: true, -) -#set par(leading: 5.6pt, spacing: 5.6pt, justify: true) - -// ─── HERO (left-aligned, the engagement leads) ── -#fit-size( - (22pt, 20pt, 19pt, 18pt), - 155mm, - s => text(font: theme.display-font, size: s, weight: 700, tracking: -0.2pt)[#data.kind-label], -) -#if data.subtitle != none [ - #v(4pt) - #text(font: theme.display-font, size: 12pt, weight: 400, fill: theme.mute)[#data.subtitle] -] - -#v(mm-sp.s) -#line(length: 100%, stroke: 0.6pt + theme.ink) -#v(mm-sp.s) - -// ─── DATED ── -#section-label(theme, "Dated") #h(6pt) #text(size: 9.6pt)[#data.effective-date-display] - -#v(mm-sp.s) - -// ─── PARTIES ── -#section-label(theme, "Parties") -#v(2pt) -#parties-prose-block(data.parties-prose, theme) - -#v(mm-sp.s) -#line(length: 100%, stroke: 0.3pt + theme.hair) -#v(mm-sp.s) - -// ─── KEY TERMS ── -#let cells = (("Term", data.term-short), ("Governing law", data.governing-law)) -#if data.fee-short != none { - cells = cells + (("Fee", data.fee-short),) -} -// Columns adapt to cell count: each cell always takes one third of the -// measure, so a 2-cell strip keeps the 3-cell rhythm instead of spreading -// the second cell out to the centreline. -#grid( - columns: cells.map(_ => (100% - 16mm) / 3), - column-gutter: 8mm, - align: (left + horizon, left + horizon, left + horizon), - ..cells.map(((lbl-t, val)) => [ - #text(size: 7.5pt, fill: theme.mute, tracking: 1.2pt)[#upper(lbl-t)]\ - #v(1pt) - #text(size: 9.5pt)[#val] - ]) -) - -#v(mm-sp.s) -#line(length: 100%, stroke: 0.3pt + theme.hair) -#v(mm-sp.m) - -// ─── AGREED TERMS ── -#section-label(theme, "Agreed terms") -#v(mm-sp.xs) - -// ─── CLAUSES (mute number, no accent) ── -#for clause in data.clauses { - block(breakable: true, spacing: mm-sp.s, [ - #grid( - columns: (8mm, 1fr), - column-gutter: 4mm, - align: (right + top, left + top), - text(font: theme.display-font, size: 10pt, weight: 600, fill: theme.mute)[#clause.number.], - [ - #text(font: theme.display-font, size: 10.5pt, weight: 700)[#clause.heading] - #v(2pt) - #render-markdown(clause.body) - ], - ) - ]) -} - -// ─── SIGNATURE ── -#v(mm-sp.l) -#signature-block(data.signature, theme) +#modern-contract(theme, character: "helvetica-nera") diff --git a/typst/templates/marrakech.typ b/typst/templates/marrakech.typ index 6a4174f..41ede33 100644 --- a/typst/templates/marrakech.typ +++ b/typst/templates/marrakech.typ @@ -1,128 +1,12 @@ -//! description: Data-room deal voice — Iowan Old Style on cream, terracotta section labels, ruled key-terms table. Patrician and warm, built for sophisticated counterparties. +//! description: Warm editorial agreements in Literata, with a terracotta accent and restrained cream paper. //! mood: warm, patrician, editorial -//! tags: iowan, cream, terracotta, data-room, warm, deal-memo, patrician, editorial -//! fonts: Iowan Old Style (system), Literata (embedded OFL fallback) +//! tags: cream, terracotta, data-room, warm, deal-memo, patrician, editorial +//! fonts: Literata (bundled OFL) //! paper: cream -// ═══════════════════════════════════════════════════════════════════════════ -// marrakech — the Iowan/cream/terracotta data-room voice. -// Tokens from the Marrakech brief: cream #F6F1E7 paper, near-black ink, -// one terracotta accent, hairline rules, tracked uppercase labels, weights -// capped at 500-600. Key terms set as a proper ruled table. No decoration -// the brief didn't have. -// ═══════════════════════════════════════════════════════════════════════════ - -#import "../shared/contract.typ": data, lbl, hairline, fit-size, parties-prose-block, signature-block, page-shell, render-markdown, section-label, sp, mm-sp - +#import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#1D1A16"), - paper: rgb("#F6F1E7"), - accent: rgb("#8A3A1F"), - mute: rgb("#6B665E"), - hair: rgb("#D9D2C5"), - watermark: rgb("#E4D8C4"), - display-font: ("Iowan Old Style", "Literata", "Georgia"), - body-font: ("Iowan Old Style", "Literata", "Georgia"), - mono-font: ("Menlo", "DejaVu Sans Mono"), - label-style: "upper", - margin: (top: 26mm, bottom: 24mm, left: 26mm, right: 26mm), + ink: rgb("#342F28"), paper: rgb("#FBF8F1"), accent: rgb("#87513E"), + mute: rgb("#696052"), hair: rgb("#D9CDBF"), watermark: rgb("#EEECE8"), + display-font: "Literata", body-font: "Literata", body-size: 11.25pt, title-weight: 500, ) - -#show: body => page-shell(theme, body) - -#set text( - font: theme.body-font, - size: 10.2pt, - fill: theme.ink, - lang: "en", - number-type: "lining", - hyphenate: true, -) -#set par(leading: 6.4pt, spacing: 6.4pt, justify: true) - -// Terracotta section label — the h2 of the Marrakech system. -#let mk-label(txt) = text( - font: theme.display-font, size: 9pt, weight: 500, - tracking: 1.6pt, fill: theme.accent, -)[#upper(txt)] - -// ─── TITLE ── -#fit-size( - (23pt, 21pt, 19pt, 17pt), - 158mm, - s => text(font: theme.display-font, size: s, weight: 500, tracking: 0pt)[#data.kind-label], -) -#if data.subtitle != none [ - #v(4pt) - #text(font: theme.display-font, size: 12pt, style: "italic", fill: theme.mute)[#data.subtitle] -] - -#v(mm-sp.s) -#hairline(theme, weight: 0.4pt) -#v(mm-sp.s) - -// ─── DATED ── -#mk-label("Dated") #h(6pt) #text(size: 10.2pt)[#data.effective-date-display] - -#v(mm-sp.s) - -// ─── PARTIES ── -#mk-label("Parties") -#v(2pt) -#parties-prose-block(data.parties-prose, theme) - -#v(mm-sp.m) - -// ─── KEY TERMS (proper ruled table) ── -#mk-label("Key terms") -#v(3pt) -#let terms = (("Term", data.term-short), ("Governing law", data.governing-law)) -#if data.fee-short != none { - terms = terms + (("Fee", data.fee-short),) -} -#let n-terms = terms.len() -#block(breakable: false, table( - columns: (42mm, 1fr), - stroke: (x, y) => ( - top: if y == 0 { 0.5pt + theme.hair } else { 0.3pt + theme.hair }, - bottom: if y == n-terms - 1 { 0.5pt + theme.hair } else { none }, - ), - inset: (x: 0pt, y: 5.5pt), - align: (left + horizon, left + horizon), - ..terms - .map(((lbl-t, val)) => ( - text(size: 8pt, fill: theme.mute, tracking: 1.2pt)[#upper(lbl-t)], - text(size: 10.2pt)[#val], - )) - .flatten() -)) - -#v(mm-sp.m) - -// ─── AGREED TERMS ── -#mk-label("Agreed terms") -#v(mm-sp.xs) - -// ─── CLAUSES (terracotta number, serif heading at weight 500) ── -#for clause in data.clauses { - block(breakable: true, spacing: mm-sp.s, [ - #grid( - columns: (8mm, 1fr), - column-gutter: 4mm, - align: (right + top, left + top), - text(font: theme.display-font, size: 11pt, weight: 500, fill: theme.accent)[#clause.number.], - [ - #text(font: theme.display-font, size: 11pt, weight: 600)[#clause.heading] - #v(2pt) - #render-markdown(clause.body) - ], - ) - ]) -} - -// ─── SIGNATURE (closing rule travels with the block) ── -#v(mm-sp.l) -#block(breakable: false, [ - #hairline(theme, weight: 0.4pt) - #v(mm-sp.s) - #signature-block(data.signature, theme) -]) +#modern-contract(theme, character: "marrakech") diff --git a/typst/templates/vienna-legal.typ b/typst/templates/vienna-legal.typ index a93bb16..fc79810 100644 --- a/typst/templates/vienna-legal.typ +++ b/typst/templates/vienna-legal.typ @@ -1,127 +1,12 @@ -//! description: Warm boutique consulting look — cream paper, terracotta accent, centred title, Bauhaus poise. +//! description: Warm boutique agreements in Libre Franklin with a restrained terracotta accent. //! mood: warm, friendly, boutique -//! tags: warm, cream, terracotta, boutique, bauhaus, colorful, centred, vienna, accent -//! fonts: Helvetica Neue (system) +//! tags: warm, cream, terracotta, boutique, bauhaus, colorful, vienna, accent +//! fonts: Libre Franklin (bundled OFL) //! paper: cream -// ═══════════════════════════════════════════════════════════════════════════ -// vienna-legal — Warm boutique consulting contract. -// Cream paper, terracotta accent. Centred title. -// -// Real-contract conventions: kind-label IS the title; project name (if any) -// is the subtitle. "Dated 24 May 2026" line above PARTIES. Numbered (1)/(2) -// parties prose, not invoice-style two-column cards. Reference code only -// in the page footer for filing. -// ═══════════════════════════════════════════════════════════════════════════ - -#import "../shared/contract.typ": data, lbl, hairline, fit-size, parties-prose-block, signature-block, page-shell, render-markdown, section-label, sp, mm-sp - +#import "../shared/modern.typ": modern-contract #let theme = ( - ink: rgb("#1B1B1B"), - paper: rgb("#F5F0E6"), - accent: rgb("#B94735"), - mute: rgb("#6E685D"), - hair: rgb("#C7BFAE"), - watermark: rgb("#E6CFC1"), - display-font: ("Helvetica Neue", "Helvetica", "Inter", "Arial"), - body-font: ("Helvetica Neue", "Helvetica", "Inter", "Arial"), - mono-font: ("Menlo", "DejaVu Sans Mono"), - label-style: "upper", - margin: (top: 24mm, bottom: 22mm, left: 24mm, right: 24mm), + ink: rgb("#302B27"), paper: rgb("#FCFAF5"), accent: rgb("#8A4D38"), + mute: rgb("#665D55"), hair: rgb("#D6CEC3"), watermark: rgb("#EEECE8"), + display-font: "Libre Franklin", body-font: "Libre Franklin", body-size: 11.25pt, title-weight: 500, ) - -#show: body => page-shell(theme, body) - -#set text( - font: theme.body-font, - size: 9.6pt, - fill: theme.ink, - lang: "en", - number-type: "lining", - number-width: "tabular", - hyphenate: false, -) -#set par(leading: 5.6pt, spacing: 5.6pt, justify: true) - -// ─── HERO ── -#align(center)[ - #if data.logo != none [ - #image(data.logo, height: 7mm) - #v(8pt) - ] - #fit-size( - (24pt, 22pt, 20pt, 18pt), - 150mm, - s => text(font: theme.display-font, size: s, weight: 700, tracking: 0pt, fill: theme.ink)[#data.kind-label], - ) - #if data.subtitle != none [ - #v(6pt) - #fit-size( - (13pt, 12pt, 11pt), - 150mm, - s => text(font: theme.display-font, size: s, weight: 400, fill: theme.mute)[#data.subtitle], - ) - ] - #v(8pt) - #rect(width: 32mm, height: 1.4pt, fill: theme.accent, stroke: none) -] - -#v(mm-sp.m) - -// ─── DATED ── -#section-label(theme, "Dated") #h(6pt) #text(size: 9.6pt)[#data.effective-date-display] - -#v(mm-sp.s) - -// ─── PARTIES ── -#section-label(theme, "Parties") -#v(2pt) -#parties-prose-block(data.parties-prose, theme) - -#v(mm-sp.s) -#line(length: 100%, stroke: 0.3pt + theme.hair) -#v(mm-sp.s) - -// ─── KEY TERMS (docket row) ── -#let cells = (("Term", data.term-short), ("Governing law", data.governing-law)) -#if data.fee-short != none { - cells = cells + (("Fee", data.fee-short),) -} -#grid( - columns: cells.map(_ => 1fr), - column-gutter: 8mm, - align: (left + horizon, left + horizon, left + horizon), - ..cells.map(((lbl-t, val)) => [ - #text(size: 7.5pt, fill: theme.mute, tracking: 1pt)[#upper(lbl-t)]\ - #v(1pt) - #text(size: 9.2pt)[#val] - ]) -) - -#v(mm-sp.s) -#line(length: 100%, stroke: 0.3pt + theme.hair) -#v(mm-sp.m) - -// ─── AGREED TERMS heading ── -#section-label(theme, "Agreed terms") -#v(mm-sp.xs) - -// ─── CLAUSES ── -#for clause in data.clauses { - block(breakable: true, spacing: mm-sp.s, [ - #grid( - columns: (8mm, 1fr), - column-gutter: 4mm, - align: (right + top, left + top), - text(font: theme.display-font, size: 9.6pt, weight: 700, fill: theme.accent)[#clause.number.], - [ - #text(font: theme.display-font, size: 10.4pt, weight: 700)[#clause.heading] - #v(2pt) - #render-markdown(clause.body) - ], - ) - ]) -} - -// ─── SIGNATURE (never splits across pages) ── -#v(mm-sp.l) -#signature-block(data.signature, theme) +#modern-contract(theme, character: "vienna-legal") From 6f09c2acc256dd61a531bb86694613c29a2106a8 Mon Sep 17 00:00:00 2001 From: Paperfoot Date: Thu, 10 Sep 2026 23:22:23 +0100 Subject: [PATCH 3/3] Preserve heading spacing around grouped execution clauses --- typst/shared/modern.typ | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/typst/shared/modern.typ b/typst/shared/modern.typ index 4f90f31..b45ecd3 100644 --- a/typst/shared/modern.typ +++ b/typst/shared/modern.typ @@ -116,11 +116,11 @@ if index == data.clauses.len() - 1 { // Keep a modest closing clause with execution when the measured // content fits comfortably on a page. Long clauses remain flowing. - layout(size => { + block(above: 19pt, layout(size => { let closing = [#clause-content(clause)#execution(theme)] let height = measure(closing, width: size.width).height block(breakable: height > 180mm, closing) - }) + })) } else { clause-content(clause) } } #if data.clauses.len() == 0 { execution(theme) }