# Contracts: templates, clauses and our own e-signature. The module source.
#
# PROVENANCE. Derived from a pilot engagement, 2026-10-17 (a creative agency; employment contracts,
# freelance services agreements with IP assignment, NDAs, leaver letters). The code it is rebuilt from is
# an earlier client CRM (its contract and clause domain, public tokens, the contract tables and the
# contract, clause library, versions and record-files pages). How far each part is signed off:
# - SIGNED OFF BY THE CUSTOMER: nothing yet. The need is "generating the relevant contract from an
# approved template", in the customer's words; G-03 and G-11 are requirements written from it, not
# yet reviewed by the customer.
# - AGREED (boundary review, 2026-10-17): contracts are core; the e-signature level is SIMPLE (SES) with
# an evidence trail; advanced/qualified signatures are out of scope until a customer needs one; the
# customer's template wording is an adjustment (invented wording in the demo).
# - DESIGNED (2026-10-17, DODIL's decisions over contracts best-practice research): everything else.
# Where this spec differs from that research, the decisions win, notably: the counterparty is ALWAYS
# a bp_id (never "bp_id or employee_id"), with the business-partner roles each type allows.
#
# What a contracts module usually gets wrong, and this one does not:
# - editing the library alters signed paper: here a contract holds a COPY of each clause version, frozen
# - "signed" is a status somebody set: here it is an executed copy backed by an append-only, hash-chained
# event log, or an uploaded executed copy marked SIGNED_ELSEWHERE, never both confused
# - overclaiming: this is a simple electronic signature, and the module only ever says "signed electronically"
#
# Extra top-level sections (brick, domain_model, surfaces, provides_capabilities, localisation_packs,
# extension_points) are passed through verbatim in /modules/contracts/spec.yaml for the builders.
id: contracts
name: Contracts & e-signature
short: Contracts
version: 1
maturity: tested # built and tested, not deployed (pattern §14): rules, both doors, tier 1 and the scenarios pass; live_check ran on DataK3; nothing has run behind the gateway for a real person
summary: >-
Contracts drafted from your approved templates and clause library, filled from the record rather
than re-typed, approved when they deviate, signed online with a record of who signed what and
when, and tracked to their notice deadlines and renewals.
facets:
domain: legal
industries: [any]
customer_words:
- "generate the contract from an approved template"
- "where is the signed contract"
- "renewal dates"
- "what did we agree"
- "send it for signature"
- "our standard clauses"
- "who still hasn't signed"
replaces: [a shared drive of Word templates, DocuSign for simple contracts, Ironclad, Juro]
pillars: [sql]
first_module_fit: low
depends_on:
- module: business-partner
why: every counterparty is a business partner (an organisation, or a person partner hr writes); its data is read through bp and frozen at send
required: true
feeds: [hr, crm]
owns_master_data: []
references_master_data: [business_partner, person]
# ---------------------------------------------------------------------------------------------
# The brick: the same shape as brick-pipeline / brick-quote / brick-business-partner / brick-activity
# ---------------------------------------------------------------------------------------------
brick:
package: brick_contracts
short_name: contract # the permission namespace; tables are prefixed ct_ (shared.yaml)
layout:
- "src/brick_contracts/domain/ rules in plain Python: Contract, Library (templates, clauses), ContractType; value objects; domain events. No SQLAlchemy, FastAPI, MCP, identity or PDF engine."
- "src/brick_contracts/application/ use cases declared ONCE with @CT.use_case (an API route AND an MCP tool), `caller` first; row visibility and the sensitivity class decided here; the provided capabilities contracts.draft / send / status"
- "src/brick_contracts/infrastructure/ tables + one repository per aggregate (erp_platform.repository Aggregate/Part); the PDF renderer, the CapabilitySeam (mail.send) and the public-door client live here"
- "src/brick_contracts/app.py app = CT.app()"
- "src/brick_contracts/schema.sql inside the package; additive only"
- "tests/test_rules.py rules with no database (one test per Given/When/Then sentence)"
- "tests/test_doors.py API and MCP over the real app with storage on SQLite / the in-memory repository: same outcome both ways, 404 for another caller's or a sensitive-grade row, 403 without the permission"
- "tests/test_tier1.py with the real business-partner and hr bricks in one bucket"
- "scripts/live_check.py the deployed brick against a dev bucket"
- "README.md · AGENTS.md · CLAUDE.md, like brick-pipeline's"
refusals: "Refused(status, reason) in the rule's own words: 409 for a broken rule (\"version 2 is out for signature; recall it to edit\"), 422 for invalid input (missing[] listed), 404 for a row the caller may not see, never 403 for it"
no_hand_written_doors: "no api/ or mcp/ folder: both doors are generated from the use-case declarations"
pdf_renderer: "an infrastructure adapter (WeasyPrint in the earlier client CRM, +99 MB of system libraries), so it lives in the PRIVATE app's image only; the public signing page never renders a PDF, it serves the frozen bytes"
use_cases:
commands: [keep_contract_type, keep_clause, amend_clause, retire_clause, keep_template, approve_template, retire_template,
draft_contract, edit_draft, choose_position, override_section, set_variables, add_signer, edit_signer,
request_approval, decide_approval, send_for_signature, recall, reissue_link, copy_signing_link,
mark_delivered_by_hand, upload_signed_elsewhere, cancel, amend_contract, renew_contract, terminate_contract,
record_administrative_change, complete_key_date, run_due, purge_by_retention]
public_commands: [open_for_signing, consent_and_sign, decline, request_changes] # through the door only
internal_commands: [sign_as_us, decline_as_us] # our-side signers, signed in
queries: [list_contracts, show_contract, show_versions, show_certificate, verify_evidence, approvals_queue,
key_dates, clause_library, show_template, contract_status, fetch_executed, to_sign]
outside_the_brick:
- "crm's draft_from_quote is an ADAPTER in the composition (or in crm), never in this brick: it maps quote fields to variables and calls contracts.draft with source_ref 'crm:quote/<quote_id>@v<version>'. The brick has no quote code path."
# ---------------------------------------------------------------------------------------------
# Domain model (DDD)
# ---------------------------------------------------------------------------------------------
domain_model:
ubiquitous_language:
contract_type: "what kind of agreement (EMPLOYMENT, FREELANCE_SERVICES, NDA, IP_ASSIGNMENT, LEAVER_LETTER, CUSTOMER_ORDER, SUPPLIER…): a row carrying the allowed counterparty roles, execution (SIMPLE | DEED), esign_allowed, sensitivity, the retention rule and the approval policy"
counterparty: "ALWAYS a business partner (bp_id), of a role the type allows (employee, worker, supplier, customer)"
template: "one approved shape per contract type: ordered clause slots and typed variable definitions, versioned"
clause: "a standing piece of wording, versioned; it belongs to a family (liability cap, IP) with a POSITION: Preferred, Alternate or Fallback (SAP Ariba's vocabulary)"
position: "Preferred = standard; Alternate = an agreed other wording (deviation 0 or 1 per row); Fallback = acceptable with approval (deviation 1); an override written for one contract = non-standard (deviation 2)"
slot: "a place in a template: required | optional | alternative-of <slot>, naming a clause family and its default position"
variable_def: "a typed field a template needs (text, date, money, int, enum, bool, party), with its source (COUNTERPARTY, ENGAGEMENT, PAY, DATES, QUOTE, CALLER), required, validation bounds and conditions that choose clauses"
variable: "the resolved value of a variable_def on one contract, with its source; frozen at send"
section: "a COPY of a clause version inside one contract, with its deviation level and whether it was overridden"
draft_fingerprint: "sha256 over the ordered sections, the resolved variables and the signer list; any edit changes it"
deviation: "a section or variable away from the template's standard: a Fallback position, an override, or a variable outside its bounds"
approval: "a decision on one draft_fingerprint by the group a policy row names, with a mandatory fallback group"
version: "a frozen rendering of the contract (PDF bytes + sha256) made at send; every signature binds to exactly one version"
signer: "a person who signs for one side (US | THEM), in a signing order, with a title and an authority basis; of kind INTERNAL (our side: signs inside the app as their signed-in pool identity) or EXTERNAL (the counterparty: signs through a public link)"
signature_event: "an append-only, hash-chained record of what happened to a signer and a version (INVITED, DELIVERED, OPENED, CONSENTED, SIGNED, DECLINED, CHANGES_REQUESTED, REVOKED, REISSUED, SIGNED_ELSEWHERE)"
executed_copy: "the signed version's PDF plus a certificate page, with its own sha256 kept in the row; or an uploaded executed copy (provenance UPLOADED)"
signed_electronically: "the ONLY claim this module makes about a signature: a simple electronic signature with its evidence attached"
amendment: "a change to a signed contract: AMENDMENT, RENEWAL, TERMINATION (each its own versioned, signed contract) or ADMINISTRATIVE (internal, not signed)"
key_date: "a dated obligation on a contract (START, END, NOTICE_DEADLINE, RENEWAL, PROBATION_END, PRINCIPAL_STATEMENT_DUE, SDS_REVIEW, PAYMENT_MILESTONE) with an owner and reminders"
source_ref: "the opaque id of what asked for the contract (hr:onboarding/…, crm:quote/…@v3, manual:<form key>); its namespace (hr:, crm:) names the ORIGINATING consumer"
originator: "the consumer whose capability call created a contract, matched by the source_ref namespace; only it may fetch that contract's executed copy"
bounded_context:
inside: "contract types, the clause library, templates, contracts and their sections, variables, versions, signers, approvals, signature evidence, executed copies, amendments, key dates, retention of all of these"
outside: "the party record (business-partner), why a contract is needed (hr, crm), delivering mail (a provider), the public token (the platform door), the legal judgement of status or validity (the customer and their adviser)"
context_map:
- neighbour: business-partner
relationship: "conformist reader: the counterparty is read through the bp seam at draft (bp:person:read for a person partner) and FROZEN into variables at send; contracts never writes bp"
- neighbour: hr
relationship: "customer/supplier: hr is the customer; contracts PROVIDES contracts.draft / send / status / fetch_executed as capabilities; hr polls status and, as the ORIGINATOR, fetches the executed copy of contracts it created; contracts never reads hr tables"
- neighbour: crm
relationship: "an ANTI-CORRUPTION LAYER outside the brick: draft_from_quote maps a quote to variables and calls contracts.draft"
- neighbour: mail provider (none in the first demo)
relationship: "an ACL through mail.send (capabilities.yaml 0.3); no provider means a manual 'copy the signing link' task"
- neighbour: the public-link door (erp-platform)
relationship: "the door holds the signing token (hashed) and calls contracts' public use cases with a service identity scoped to one signer of one contract"
aggregates:
- name: Contract
root: "ct_contract (contract_id)"
parts: [ct_contract_variable, ct_contract_section, ct_contract_version, ct_signer, ct_signature_event, ct_executed_copy, ct_approval, ct_delivery, ct_key_date, ct_amendment]
invariants:
- "the counterparty is one bp_id holding a role the contract type allows, checked on draft"
- "sections and variables are editable only in DRAFT; at send they are frozen into version N (PDF + sha256)"
- "every signature event names one version and the sha256 the signer was shown; a SIGN for any version other than the current frozen one is refused (earlier client CRM, defect 3)"
- "an approval binds to one draft_fingerprint; any edit changes the fingerprint and voids it"
- "send is refused while missing[] is non-empty, while a required approval is not granted, when execution = DEED or esign_allowed = false, and when both sides are not named"
- "signature events are append-only and hash-chained (prev_hash → row_hash); a broken chain blocks the executed copy and is shown"
- "SIGNED requires an executed copy AND a SIGNED event for every signer of the signed version; SIGNED_ELSEWHERE requires an uploaded executed copy and marks signers SIGNED_ELSEWHERE, never SIGNED (earlier client CRM, defect 4)"
- "the executed copy is a real rendered file with its own sha256, never a placeholder (earlier client CRM, defect 1)"
- "IP and user agent are structured fields of the event, never free text (earlier client CRM, defect 2)"
- "an INTERNAL signer signs only inside the app, as their pool identity (sub + session); an EXTERNAL signer only through a public link; neither path can sign for the other kind"
- "the executed copy is released to a consumer only if it is the originator (source_ref namespace = the caller's brick)"
enforced_in: domain
- name: Library
root: "ct_template (template_id) and ct_clause (clause_id): each its own small aggregate"
parts: [ct_template_version, ct_template_slot, ct_variable_def, ct_clause_version]
invariants:
- "a clause version a contract has taken is frozen; a change is a new version (the earlier client CRM's clause domain)"
- "a template version is DRAFT → APPROVED → RETIRED; only APPROVED drafts contracts; an approved version is immutable"
- "a template version pins clause_id@version per slot, so approving a template approves those exact words"
- "a required clause cannot be retired while a template still requires it"
enforced_in: domain
- name: ContractType
root: "ct_type (type_code)"
invariants: ["a DEED type has esign_allowed = false", "every type names a retention rule and an approval policy with a fallback group"]
transactions: "one aggregate per transaction; the tick (run_due) and the capability calls are each idempotent, and cross-aggregate effects (a template retired, a contract still on it) are read, never cascaded"
value_objects:
Fingerprint: "sha256 hex of a canonical serialisation: of the draft (sections + variables + signers) for approvals, of PDF bytes for a version, of the executed file"
VariableValue: "a typed value + its source + resolved_at; money as an exact decimal string with currency"
KeyDate: "kind + due_on + owner group + reminder cadence; NOTICE_DEADLINE = end − notice period, computed, stored as its own key date"
SignerRef: "contract_id|signer_no"
EventHash: "row_hash = sha256(prev_hash || canonical row); prev_hash of the first event = sha256(contract_id)"
ConsentText: "the exact wording shown, with its version"
ContractId: "derived: 'ct-' + hex(blake2b(source_ref|type_code, digest_size=8)), so a retried caller makes one contract"
domain_events: # past tense; written as ct_signature_event / state rows, and reported by contracts.status
Drafted: "a contract was drafted from an approved template version (missing[] and deviations[] computed)"
Approved: "an approval was granted for one draft fingerprint"
Sent: "version N was frozen and signing links issued"
Signed: "every signer of version N signed; the executed copy exists"
Declined: "a signer declined, with a reason (or requested changes)"
Expired: "OUT_FOR_SIGNATURE passed its link lifetime unsigned (EXPIRED_UNSIGNED), or an ACTIVE contract passed its end"
Amended: "an amendment, renewal or termination was signed and the original superseded or terminated"
domain_services:
Resolver: "(template version, counterparty bp record, supplied values) → variables + missing[] + clause choices by condition"
DeviationCheck: "(draft, template version, type policy) → deviations[] with levels, and whether approval is required"
Renderer: "(frozen sections + variables) → PDF bytes (infrastructure adapter behind a domain port)"
CertificateBuilder: "(signed version, events, signers) → the certificate page, using only the words 'signed electronically'"
ChainVerifier: "(events) → valid | broken at seq n"
policies:
- "contract types: allowed counterparty roles, execution, esign_allowed, sensitivity, retention rule"
- "approval policy rows: which deviation or variable bound needs which group, and the mandatory fallback group and timeout"
- "reminder cadences: signer chasing; key-date reminders (default 120/90/60/30 days for notice deadlines)"
- "link lifetime and the consent text version"
- "retention rules per type (term + 6 years; deeds 12)"
factories:
ContractFactory: "from contracts.draft (or draft_contract): type + approved template version + counterparty + values → a Contract in DRAFT with sections copied, variables resolved, missing[] and deviations[]"
AmendmentFactory: "from a signed contract + kind → a new Contract (amends = parent) in DRAFT, prefilled from the parent's frozen variables"
repositories: "ContractRepository, TemplateRepository, ClauseRepository, ContractTypeRepository: each an Aggregate of Parts whose keys start with the root key; whole-aggregate loads, no lazy loading"
actors:
- id: contract_admin
name: Operations / legal (whoever keeps the standard paper)
does: keeps contract types, the clause library and templates, approves template versions, sets approval policies and settings
sees: every contract, including employment bodies
permissions: [contract:type:write, contract:clause:write, contract:template:write, contract:template:approve, contract:contract:read_all,
contract:employment:read, contract:contract:write, contract:contract:send, contract:contract:recall,
contract:contract:upload_executed, contract:evidence:read, contract:amendment:write, contract:settings:write]
- id: owner
name: The person responsible for a contract (account lead, HR, operations)
does: drafts, edits, sends, chases, recalls, files signed-elsewhere copies, amends and renews their contracts
sees: contracts they own; employment bodies only with contract:employment:read
permissions: [contract:contract:read, contract:contract:write, contract:contract:send, contract:contract:recall,
contract:contract:upload_executed, contract:evidence:read, contract:amendment:write]
- id: approver
name: An approver (legal, finance, HR lead, a director), or the fallback approver
does: approves or rejects a draft that deviates from the standard
sees: the drafts in their queue, with the deviations highlighted
permissions: [contract:approval:decide, contract:contract:read]
- id: signer
name: The counterparty's signer (the starter, a freelancer, a client)
does: reads the frozen version, consents, signs, declines or asks for changes, downloads the signed copy
sees: one contract version through a link (READ + SIGN), nothing else
permissions: [] # holds a public-link token, not a pool role
- id: our_signatory
name: Our side's signer (a director, an authorised signatory)
does: signs or declines inside the app, signed in, from a To sign list
sees: the contracts where they are a signer, at the version out for signature
permissions: [contract:contract:sign]
- id: calling_module
name: Another module asking for a contract (hr; crm through its adapter), through its service identity
does: calls contracts.draft / send / status
sees: the contracts it caused (by source_ref namespace), their state and key dates, and, as their originator, the executed copy; never a body of a contract it did not create, and no broad contract:employment:read
permissions: [contract:capability:invoke]
- id: clock
name: The clock (a service client or the always-on worker)
does: "calls run_due(now). ONE clock worker (the clock service client) calls every brick's tick route, listed in the composition"
sees: nothing through people's doors
permissions: [contract:run:tick]
- id: system
name: The system
does: renders versions and executed copies, chains events, chases signers, reminds on key dates, applies retention
sees: everything, through its own service account
permissions: []
stories:
# person
- id: CT-01
kind: person
actor: contract_admin
story: As whoever keeps our standard paper, I want a clause library with Preferred, Alternate and Fallback positions, so that negotiation happens inside agreed limits.
given: the family "IP assignment" with a Preferred clause (full assignment on creation) and a Fallback (assignment on payment)
when: I amend the Preferred wording
then: >-
a new version is cut and old versions stay readable; contracts already holding version 1 are untouched;
editing a version a contract holds is refused ("version 1 is on 3 contracts, amend it into version 2");
a required clause cannot be retired while a template requires it
screen: Clause library
reference: "partial — the earlier client CRM's clause domain versions and freezes; positions and families are new"
- id: CT-02
kind: person
actor: contract_admin
story: As whoever keeps our standard paper, I want contract types as rows, so that what may be signed online, with whom and kept how long is ours to set.
given: the types EMPLOYMENT, FREELANCE_SERVICES, NDA, LEAVER_LETTER and a DEED type
when: I set FREELANCE_SERVICES to allow the counterparty roles worker and supplier, and the DEED type to execution DEED
then: >-
a freelance contract to a partner with only the customer role is refused; the DEED type is saved with
esign_allowed = false and can never be sent for online signing; every type must name a retention rule and an
approval policy with a fallback group, or the save is refused
screen: Contract types
reference: not built
- id: CT-03
kind: person
actor: contract_admin
story: As whoever keeps our standard paper, I want one template per type with typed variables, so that a contract is filled from the record and not re-typed.
given: a draft employment template version
when: I order its slots (required, optional, alternative-of) and declare variables (start_date DATE from DATES, salary MONEY from PAY with a band, job_title TEXT from ENGAGEMENT, notice_weeks INT with a minimum)
then: >-
each slot pins clause_id@version; each variable has a type, a source, whether it is required, its bounds
and any condition choosing a clause ("engagement_via = PSC → the PSC warranty slot"); the version stays DRAFT until approved
screen: Templates
reference: "not built — the earlier client CRM's contract_template is a JSON list of clause ids"
- id: CT-04
kind: person
actor: contract_admin
story: As whoever keeps our standard paper, I want template versions approved before use, so that nothing goes out on unapproved words.
given: template version 3 in DRAFT, authored by Sam
when: Priya approves it
then: >-
version 3 is APPROVED and immutable, new drafts use it, contracts on version 2 keep version 2; the author
approving their own version is refused unless the setting allows it; retiring it stops new drafts only
screen: Templates
reference: not built
- id: CT-05
kind: person
actor: owner
story: As a contract owner, I want to draft a contract for a partner by choosing its type, so that the right template, clauses and values appear at once.
given: an organisation partner with the supplier role and the NDA type
when: I draft it (draft_contract), with a form key generated when the form opened
then: >-
a DRAFT exists with sections copied from the approved template version, variables resolved from the partner
(read through bp) and from what I typed, and a readiness panel listing missing[] and deviations[]; a double
click is one draft (source_ref manual:<form key>)
screen: Contract editor
reference: "partial — the earlier client CRM drafts from a quote; type-driven drafting is new"
- id: CT-06
kind: person
actor: owner
story: As a contract owner, I want to choose an alternative position or rewrite a clause for one deal, so that negotiation is recorded as exactly that.
given: a draft with the Preferred liability clause
when: I switch it to the Fallback position, or override its words for this contract
then: >-
the section records its position and deviation level (Fallback 1, override 2, marked overridden); the
library is untouched; deviations[] now requires approval and the send button says so ("liability cap is a
Fallback: approval by legal needed")
screen: Contract editor
reference: "partial — the earlier client CRM overrides sections; deviation levels are new"
- id: CT-07
kind: person
actor: approver
story: As an approver, I want to approve or reject a deviating draft from a queue, so that margin and risk are protected without email.
given: a draft whose fingerprint F1 has a Fallback clause and a day rate above its band
when: I approve it
then: >-
the approval binds to F1; any later edit (new fingerprint F2) voids it and the draft needs approval again;
a rejection returns it to DRAFT with my reason; an approval not decided by its timeout goes to the policy's
mandatory fallback group, so nothing stalls; an owner cannot approve their own deviation
screen: Approvals
reference: not built
- id: CT-08
kind: person
actor: owner
story: As a contract owner, I want to send for signature in one step, so that each signer gets their own link and the words are frozen.
given: a draft with nothing missing, its approval granted (if one was needed), signers named on both sides
when: I send it (send_for_signature)
then: >-
version N is rendered to PDF and frozen with its sha256; one signing link per signer (READ + SIGN) is issued
by the platform door for each EXTERNAL signer only; delivery goes through mail.send in signing order, or, with no
mail provider, becomes a manual "copy the signing link" task per signer (copying revokes the old token, writes a
REISSUED event into the chain and shows the new link once); each INTERNAL signer (our side) gets the contract on
their To sign list instead, with no link. INVITED and DELIVERED events are recorded. Refused, in words, when missing[] is non-empty, an approval is open, the
type is a deed, or only one side is named.
screen: Contract editor
reference: "partial — the earlier client CRM freezes a PDF with sha256 and makes links staff copy by hand; delivery is new"
- id: CT-09
kind: person
actor: signer
story: As the counterparty's signer, I want to read exactly what I am signing and sign it from a link, so that signing takes a minute and I keep proof.
given: a link to version 2 of my contract, my turn in the signing order
when: I open it, read the document and its fingerprint, tick the consent ("I agree to sign electronically"), type my name and sign
then: >-
OPENED, CONSENTED and SIGNED events are written with UTC time, version 2 and the sha256 shown to me, my email,
the token id (never the secret), IP, user agent, the typed name, the consent text and its version, signer kind
EXTERNAL and auth_method EMAIL_LINK;
the page states how long the signed copy is kept; signing a version other than the current frozen one is refused
("this contract was re-issued: open the new link"); signing out of order is refused naming who it waits on;
signing twice is one signature
screen: Signing page
reference: "partial — the earlier client CRM signs by token; structured evidence, consent text and version binding are new"
- id: CT-10
kind: person
actor: signer
story: As a signer, I want to decline or ask for changes, so that I am not forced to choose between signing and silence.
given: my signing link
when: I decline with a reason, or ask for changes with a note
then: >-
DECLINED or CHANGES_REQUESTED is recorded; the contract moves to DECLINED or back to DRAFT (a new version will
be needed); every other open link is revoked; the owner is told
screen: Signing page
reference: "partial — the earlier client CRM has decline; changes requested is new (IWG principle)"
- id: CT-11
kind: person
actor: owner
story: As a contract owner, I want to see who has opened, signed or still holds it up, so that I chase the right person.
given: a contract out for signature with three signers
when: I open its signing panel
then: >-
each signer shows seen (first and last opened) apart from acted (signed, declined); whose turn it is; I can
reissue a link (the old one stops working, REISSUED) or recall the whole contract (every token revoked, the reason recorded)
screen: Contract editor
reference: "partial — the earlier client CRM shows signer status and reissues; seen-vs-acted is new"
- id: CT-12
kind: person
actor: owner
story: As a contract owner, I want to file a copy signed elsewhere, so that a deed or the other side's paper lands on the same record with the same dates.
given: a deed (online signing not allowed), signed on paper with a witness
when: I upload the executed copy with where it was signed
then: >-
the file is stored with its sha256 as an executed copy (provenance UPLOADED); the contract is SIGNED_ELSEWHERE;
its signers are SIGNED_ELSEWHERE, not SIGNED, because no event of ours backs a signature; the key dates are
created as for any signed contract
screen: Signed-elsewhere upload
reference: "partial — the earlier client CRM's upload path exists but marks pending signers SIGNED (defect 4)"
- id: CT-13
kind: person
actor: owner
story: As a contract owner or HR, I want the certificate and the evidence, so that I can show who signed what, when and from where.
given: a signed contract
when: I open its certificate
then: >-
I see every signer (name, email, side, title, authority basis) with each event's time, IP and user agent, the
sha256 of the signed version, the sha256 of the executed file, and whether the event chain verifies; the wording is
"signed electronically. A record of who signed, when, from where, and the fingerprint of the document is
attached" — never advanced, qualified, legally binding or equivalent to a wet signature
screen: Certificate
reference: not built
- id: CT-14
kind: person
actor: owner
story: As a contract owner, I want to amend, renew or terminate a signed contract, so that the history of what was agreed is one chain.
given: a signed freelance agreement ending 31 March
when: I renew it to 30 June
then: >-
a new contract (amends = the original, kind RENEWAL) is drafted from the original's frozen variables and goes
through approval and signing like any contract; when it is signed the original becomes SUPERSEDED and the new key
dates replace the old; an ADMINISTRATIVE change (a typo in an internal reference) is recorded without signing
screen: Contract editor
reference: not built
- id: CT-15
kind: person
actor: owner
story: As a contract owner, I want a register of key dates with the notice deadline as its own date, so that we never miss the window to give notice.
given: a supplier contract ending 31 December with 90 days' notice
when: I open Key dates
then: >-
END is 31 December and NOTICE_DEADLINE is 2 October, each with an owner and reminders at 120/90/60/30 days
before (rows); completing one records who and when; a manager sees only the dates of contracts they may read
screen: Key dates
reference: not built
- id: CT-16
kind: person
actor: owner
story: As a contract owner, I want to cancel a contract before it is signed, so that a dead deal leaves no live links.
given: a contract in DRAFT or OUT_FOR_SIGNATURE
when: I cancel it with a reason
then: it is CANCELLED, every token is revoked (REVOKED events), and a signed contract cannot be cancelled ("terminate it instead")
screen: Contract editor
reference: "partial — the earlier client CRM has cancel"
- id: CT-17
kind: person
actor: our_signatory
story: As our side's signatory, I want to sign inside the app as myself, so that our signature rests on my sign-in and not on whoever holds a link.
given: a contract out for signature where I am the INTERNAL signer and it is my turn
when: I open To sign, read version 2 and its fingerprint, consent and sign (sign_as_us)
then: >-
OPENED, CONSENTED and SIGNED events with signer kind INTERNAL, auth_method POOL_SESSION, my pool sub and the
session id, UTC time, version 2 and its sha256 as shown, IP and user agent; no public link exists for me;
a caller who is not this signer gets 404; out of turn or a stale version is refused as for an external signer
screen: To sign
reference: not built
# system
- id: CT-30
kind: system
actor: system
trigger: "run_due(now), called by the same clock as hr"
story: The system chases signers, expires stale signing, and reminds on key dates, so that nobody keeps a list.
given: a signer whose turn it is and who has not signed for 3 working days; a contract out for signature past its link lifetime; a notice deadline 90 days away
when: the tick runs
then: >-
a reminder to the signer (mail.send, or a manual task); the stale contract becomes EXPIRED_UNSIGNED with its tokens
revoked; a reminder for the key date to its owner; each at most once per cadence slot (idempotency_key
contract_id|signer_no|chase|<slot>), so a second tick sends nothing
reference: not built
- id: CT-31
kind: system
actor: system
trigger: in the request of the last signature
story: The system produces the executed copy the moment the last signer signs, so that everyone holds the same evidence.
given: version 2 signed by every signer, the chain valid
when: the last SIGNED event commits
then: >-
the executed PDF = version 2 + a certificate page is rendered, its sha256 kept in the row (it cannot be printed on
itself), stored as a DataK3 object, the contract becomes SIGNED, the tokens are used up, and a copy goes to every
signer (mail.send or manual task); a broken chain refuses to produce it and alerts the owner; a retry produces it once
reference: "not built — the earlier client CRM's executed file is the placeholder signed-online:<id> (defect 1)"
- id: CT-32
kind: system
actor: system
trigger: on every signature event
story: The system writes the evidence as an append-only, hash-chained log, so that a changed row is detectable.
given: a contract's events seq 1..n
when: event n+1 is written
then: >-
its row_hash = sha256(prev_hash ‖ canonical row) with prev_hash = row n's hash; events are never updated or
deleted (within retention); the verifier recomputes the chain on every certificate read and names the first seq
that does not match; IP and user agent are fields, not text (fixes defect 2)
reference: not built
- id: CT-33
kind: system
actor: system
trigger: on read (derived)
story: The system derives the states dates decide (ACTIVE, EXPIRED) and the readiness panel, so that no job sets a flag.
given: a SIGNED contract starting 1 November and ending 31 March
when: it is read on 15 November, then on 1 April
then: ACTIVE, then EXPIRED; readiness (clauses, both sides named, term, nothing missing, approval, signed, executed copy) is computed, each item keyed so the screen can send you to the fix
reference: "partial — the earlier client CRM's readiness()"
- id: CT-34
kind: system
actor: system
trigger: daily, in the first tick after 02:00
story: The system applies the retention rule of each contract type, so that drafts do not pile up and executed contracts are kept exactly as long as they must be.
given: a declined NDA from 100 days ago, and a simple contract that ended 6 years and 1 day ago
when: the sweep runs
then: >-
the declined draft is purged after the type's short window; the expired contract, its versions and evidence are
purged at term + 6 years (12 for a deed); every purge is logged; IP and user agent go with the evidence they belong to;
running twice changes nothing
reference: not built
- id: CT-35
kind: system
actor: system
trigger: on draft and on send, for contracts under the UK pack
story: The system applies the UK checks, so that an employment statement and an IR35 decision cannot be skipped.
given: an EMPLOYMENT contract, and a FREELANCE_SERVICES contract with client_size_regime MEDIUM_LARGE and ir35_determination OUTSIDE
when: they are drafted and sent
then: >-
the employment draft lists any of the ERA 1996 s.1 day-one particulars missing as missing[], and a
PRINCIPAL_STATEMENT_DUE key date = start_date is created; contracts.status reports principal_statement_issued_at
(the duty is to GIVE it, so issued matters as much as signed) and warns when the start date passes without it;
the freelance contract is refused at send with no sds_issued_on ("an outside determination by a medium or large
client needs the status determination statement recorded first")
reference: not built
# integration
- id: CT-50
kind: integration
actor: calling_module
trigger: when hr's document step (or crm's adapter) runs
story: Another module asks for a contract by capability, so that it never writes contract tables and a retry makes one contract.
given: hr's run step with source_ref hr:onboarding/<run_id>|<step_no>, type FREELANCE_SERVICES, counterparty bp_id, values
when: it calls contracts.draft, then contracts.send, then polls contracts.status
then: >-
one contract (contract_id derived from source_ref|type_code; the same call again replays; the same key with
different inputs is 409); missing[] with no partial contract when values are absent; status reports state,
version and signers, and, to the ORIGINATOR only (the caller whose call created it, matched by the source_ref
namespace hr:), the executed copy's object key and sha256 once signed; hr then streams the file with
contracts.fetch_executed and files it on the person. Another consumer asking for it gets 404
owner: contracts
reference: "not built — the capability entries are proposed below (provides_capabilities)"
- id: CT-51
kind: integration
actor: system
trigger: at draft, and again at send
story: The counterparty's details are read through business-partner and frozen at send, so that they are never re-typed and never drift.
given: a person partner (readable with bp:person:read) or an organisation partner
when: a contract is drafted and later sent
then: COUNTERPARTY variables are resolved through the bp seam at draft and refreshed once at send, then frozen in the version; a later change to the partner never alters a sent version
owner: business-partner
reference: not built
- id: CT-52
kind: integration
actor: system
trigger: when a link, a reminder or an executed copy must reach someone
story: Contracts delivers through the mail.send capability, and with no provider makes a precise manual task.
given: compose.yaml with or without a mail.send provider
when: a delivery is due
then: >-
with a provider, mail.send with idempotency_key contract_id|signer_no|<kind>|<seq>; with none (rule 8), a manual
task for the owner carrying the exact message and recipient; for a signing link, "copy the signing link" reissues
the token and shows the link once, and marking it delivered writes DELIVERED (manual)
owner: "the capability provider (none in the first demo)"
reference: not built
- id: CT-53
kind: integration
actor: signer
trigger: when a signer opens a link
story: Signers reach the signing page through the platform's public-link door, never the staff app.
given: the door (a separate app, no pool)
when: a token is presented
then: >-
the door resolves the hash to (contracts, contract_id, signer_no) with grants READ + SIGN, checks expiry and
revocation, rate-limits, audits the use, and calls contracts' public use cases with a service identity scoped to
that one signer; a used, revoked or expired token gets the page "this link no longer works, ask <owner>"
owner: "erp-platform (the public-link door)"
reference: "not built — platform piece (the earlier client CRM keeps the token unhashed; this must not)"
- id: CT-54
kind: integration
actor: calling_module
trigger: when crm accepts a quote and wants a contract
story: crm's quote becomes a contract through an adapter outside the brick, so that contracts never learns what a quote is.
given: an accepted quote version 3
when: the adapter maps its fields to variables (QUOTE source) and calls contracts.draft with source_ref crm:quote/<quote_id>@v3
then: a draft exists like any other; the brick holds the source_ref only; re-running the adapter makes the same contract
owner: contracts
reference: "not built — replaces the earlier client CRM's draft_from_quote, which lived inside the contract service"
workflows:
- entity: contract
states: [DRAFT, IN_APPROVAL, APPROVED, OUT_FOR_SIGNATURE, SIGNED, SIGNED_ELSEWHERE, ACTIVE, EXPIRED, TERMINATED, SUPERSEDED, DECLINED, CHANGES_REQUESTED, EXPIRED_UNSIGNED, CANCELLED]
transitions:
- { from: none, to: DRAFT, by: [owner, calling_module], gate: "an APPROVED template version, a counterparty with a role the type allows" }
- { from: DRAFT, to: IN_APPROVAL, by: system, gate: "deviations[] needs approval under the type's policy" }
- { from: IN_APPROVAL, to: APPROVED, by: approver, gate: "the approval's fingerprint equals the draft's" }
- { from: IN_APPROVAL, to: DRAFT, by: approver, requires: reason, effect: "rejected" }
- { from: [IN_APPROVAL, APPROVED], to: DRAFT, by: [owner, system], gate: "any edit", effect: "the approval is void" }
- { from: [DRAFT, APPROVED], to: OUT_FOR_SIGNATURE, by: [owner, calling_module], gate: "missing[] empty; approval granted if needed; both sides named; esign_allowed", effect: "version N frozen (PDF + sha256); one token per signer; INVITED / DELIVERED" }
- { from: OUT_FOR_SIGNATURE, to: SIGNED, by: system, gate: "a SIGNED event from every signer of version N; chain valid", effect: "executed copy rendered (CT-31)" }
- { from: OUT_FOR_SIGNATURE, to: DECLINED, by: signer, requires: reason, effect: "every token revoked" }
- { from: OUT_FOR_SIGNATURE, to: CHANGES_REQUESTED, by: signer, requires: note, effect: "every token revoked; back to DRAFT for a new version" }
- { from: CHANGES_REQUESTED, to: DRAFT, by: owner }
- { from: OUT_FOR_SIGNATURE, to: DRAFT, by: owner, requires: reason, effect: "recall: every token revoked, the reason recorded; the next send is version N+1" }
- { from: OUT_FOR_SIGNATURE, to: EXPIRED_UNSIGNED, by: system, gate: "the link lifetime passed" }
- { from: [DRAFT, APPROVED], to: SIGNED_ELSEWHERE, by: owner, requires: executed_copy, effect: "provenance UPLOADED; signers SIGNED_ELSEWHERE" }
- { from: [DRAFT, IN_APPROVAL, APPROVED, OUT_FOR_SIGNATURE], to: CANCELLED, by: owner, requires: reason }
- { from: [SIGNED, SIGNED_ELSEWHERE], to: SUPERSEDED, by: system, gate: "an AMENDMENT or RENEWAL of it is signed" }
- { from: [SIGNED, SIGNED_ELSEWHERE], to: TERMINATED, by: system, gate: "a TERMINATION of it is signed" }
note: "ACTIVE and EXPIRED are DERIVED from starts_on / ends_on on a SIGNED or SIGNED_ELSEWHERE contract; nothing stores them"
- entity: template_version
states: [DRAFT, APPROVED, RETIRED]
transitions:
- { from: DRAFT, to: APPROVED, by: contract_admin, gate: "every slot pins an active clause version; the approver is not the author unless the setting allows" }
- { from: APPROVED, to: RETIRED, by: contract_admin, effect: "no new drafts; existing contracts unaffected" }
- entity: approval
states: [pending, approved, rejected, void, escalated]
transitions:
- { from: pending, to: [approved, rejected], by: approver }
- { from: [pending, approved], to: void, by: system, gate: "the draft fingerprint changed" }
- { from: pending, to: escalated, by: system, gate: "the policy's timeout passed", effect: "assigned to the mandatory fallback group" }
entities:
- id: contract_type
meaning: a kind of agreement and the rules that come with it
natural_key: type_code
key_attributes: [label, allowed_bp_roles, execution (SIMPLE | DEED), esign_allowed, sensitivity (commercial | employment), retention_rule, approval_policy, pack]
master_data: module-owned (configuration)
reference_table: ct_type
- id: clause
meaning: a standing piece of wording in a family, at a position
natural_key: clause_id
key_attributes: [family, position (PREFERRED | ALTERNATE | FALLBACK), deviation_level, category, required, status]
master_data: module-owned (Library)
reference_table: ct_clause
- id: clause_version
meaning: the words of a clause at one version
natural_key: "clause_id|version"
key_attributes: [title, body, created_by, created_at]
master_data: module-owned (Library)
reference_table: ct_clause_version
- id: template
meaning: the approved shape for one contract type
natural_key: template_id
key_attributes: [type_code, name]
master_data: module-owned (Library)
reference_table: ct_template
- id: template_version
meaning: one version of a template
natural_key: "template_id|version"
key_attributes: [status, authored_by, approved_by, approved_at]
master_data: module-owned (Library)
reference_table: ct_template_version
- id: template_slot
meaning: a place in a template version
natural_key: "template_id|version|slot_no"
key_attributes: [family, kind (required | optional | alternative_of), alternative_of_slot, clause_id, clause_version, condition]
master_data: module-owned (Library)
reference_table: ct_template_slot
- id: variable_def
meaning: a typed field a template version needs
natural_key: "template_id|version|name"
key_attributes: [type (text | date | money | int | enum | bool | party), source (COUNTERPARTY | ENGAGEMENT | PAY | DATES | QUOTE | CALLER), required, validation, enum_values, condition]
master_data: module-owned (Library)
reference_table: ct_variable_def
- id: contract
meaning: one agreement with one counterparty
natural_key: "contract_id = 'ct-' + hex(blake2b(source_ref|type_code, 8))"
key_attributes: [type_code, template_id, template_version, bp_id, context_ref, source_ref, owner, state, starts_on, ends_on, notice_days, amends_contract_id, amendment_kind, draft_fingerprint, sensitivity, retention_until, reference]
relationships: ["-> business_partner (bp_id)", "-> template_version", "0..1 -> contract it amends"]
master_data: "module-owned; contract_id is SHARED (shared.yaml) because hr stores it"
reference_table: ct_contract
note: "context_ref is opaque context (hr: person_id|n) and is never joined by this brick"
- id: contract_variable
meaning: a resolved value on one contract
natural_key: "contract_id|name"
key_attributes: [value, type, source, resolved_at, frozen_in_version]
master_data: module-owned (Contract)
reference_table: ct_contract_variable
- id: contract_section
meaning: a copy of a clause version in one contract
natural_key: "contract_id|position"
key_attributes: [clause_id, clause_version, position_kind, deviation_level, overridden, title, body]
master_data: module-owned (Contract)
reference_table: ct_contract_section
- id: contract_version
meaning: a frozen rendering sent for signature
natural_key: "contract_id|version"
key_attributes: [object_key, sha256, size_bytes, frozen_at, draft_fingerprint, status (ISSUED | SUPERSEDED | SIGNED)]
master_data: module-owned (Contract)
reference_table: ct_contract_version
- id: signer
meaning: who signs for which side
natural_key: "contract_id|signer_no"
key_attributes: [kind (INTERNAL | EXTERNAL), side (US | THEM), role (SIGNER | APPROVER | CC), signing_order, email, full_name, title, authority_basis, bp_id, pool_sub (INTERNAL), status]
master_data: module-owned (Contract)
reference_table: ct_signer
note: "keyed by number, not email (the earlier client CRM's key), so a mistyped address is corrected without a key change"
- id: signature_event
meaning: one thing that happened to a signer and a version, chained
natural_key: "contract_id|seq (append-only)"
key_attributes: [kind, signer_no, signer_kind (INTERNAL | EXTERNAL), version, version_sha256_shown, email, token_ref (EXTERNAL), pool_sub and session_id (INTERNAL), ip, user_agent, typed_name, consent_text_version, auth_method (EMAIL_LINK | POOL_SESSION), occurred_at, prev_hash, row_hash]
master_data: module-owned (Contract)
reference_table: ct_signature_event
- id: executed_copy
meaning: the signed file
natural_key: contract_id
key_attributes: [object_key, sha256, provenance (GENERATED | UPLOADED), signed_version, signed_where, filed_by, filed_at]
master_data: module-owned (Contract)
reference_table: ct_executed_copy
- id: approval
meaning: a decision on one draft fingerprint
natural_key: "contract_id|seq"
key_attributes: [draft_fingerprint, deviations, approver_group, fallback_group, due_at, state, decided_by, decided_at, reason]
master_data: module-owned (Contract)
reference_table: ct_approval
- id: delivery
meaning: something to deliver to a signer (a link, a reminder, the executed copy), by provider or by hand
natural_key: "contract_id|signer_no|kind|seq"
key_attributes: [channel (provider | manual), capability_input, status, provider_ref, done_by, done_at]
master_data: module-owned (Contract)
reference_table: ct_delivery
- id: key_date
meaning: a dated obligation on a contract
natural_key: "contract_id|kind|seq"
key_attributes: [due_on, owner_group, reminder_cadence, done_at, done_by]
master_data: module-owned (Contract)
reference_table: ct_key_date
- id: amendment
meaning: the link from a contract to what changed it
natural_key: "contract_id|seq"
key_attributes: [kind (AMENDMENT | RENEWAL | TERMINATION | ADMINISTRATIVE), child_contract_id, note, recorded_by]
master_data: module-owned (Contract)
reference_table: ct_amendment
- id: settings
meaning: approval policies, reminder cadences, link lifetime, consent texts, retention rules, our signatories
natural_key: "each by its own code"
key_attributes: [policy rows, cadence rows, consent_text versions, retention rules]
master_data: module-owned (configuration)
reference_table: [ct_approval_policy, ct_reminder_cadence, ct_consent_text, ct_retention_rule, ct_settings]
master_data:
owns: []
references:
- entity: business_partner
owner: business-partner
how: "ct_contract.bp_id = business_partner.bp_id (a person partner through bp:person:read)"
if_absent: "a contract has no counterparty this module can trust; bp is required"
- entity: person
owner: hr
how: "never joined: hr passes context_ref (person_id|n) and the values; contracts reads only the person's partner"
if_absent: "employment contracts are drafted by hand with CALLER-sourced values"
warning: >-
Re-typed counterparty data drifts. Read it through business-partner at draft, refresh it once at
send, and freeze it in the version. A second contacts list inside contracts is the bug.
jobs:
- story: CT-30
trigger: every minute or so (the same clock as hr)
runs: "POST /run-due (run_due(now)): chasing, EXPIRED_UNSIGNED, key-date reminders"
where_options: ["the ONE clock worker (the clock service client) that calls every brick's tick route listed in the composition, pinned warm (--reserved 1 --max-replicas 1)", external scheduler with a service client holding contract:run:tick]
idempotent: "contract_id|signer_no|chase|<slot>; key-date reminders keyed contract_id|kind|seq|<days before>"
- story: CT-31
trigger: the last signature
runs: render and file the executed copy
where_options: [in the same request, retried by the tick if the render failed]
idempotent: "one executed copy per contract (key contract_id)"
- story: CT-32
trigger: every event
runs: chain the row
where_options: [in the same request]
idempotent: "seq is the next integer; a concurrent duplicate is rejected 23505 and retried after re-reading prev_hash"
- story: CT-33
trigger: on read
runs: nothing stored
where_options: [derived on read]
idempotent: "nothing to repeat"
- story: CT-34
trigger: daily, first tick after 02:00
runs: retention sweep
where_options: [inside the tick (CT-30), external scheduler]
idempotent: "a purge log keyed run_date|contract_id|category"
- story: CT-35
trigger: on draft and send
runs: UK pack checks
where_options: [in the same request]
idempotent: "a check, no write beyond the key date (keyed contract_id|PRINCIPAL_STATEMENT_DUE|1)"
seams:
- story: CT-50
with: hr
owner: contracts
rule: "hr calls contracts.draft / send / status through its CapabilitySeam; contracts never reads hr tables"
- story: CT-51
with: business-partner
owner: business-partner
rule: "contracts reads partners through bp's routes (bp:person:read for persons); it never writes bp"
- story: CT-52
owner: "the mail.send provider in compose.yaml (none in the first demo)"
rule: "POST /capabilities/mail.send; no provider = a manual delivery task with the exact input"
- story: CT-53
owner: "erp-platform (public-link door)"
rule: "the door holds the hashed token and calls only open_for_signing / consent_and_sign / decline / request_changes for one signer"
- story: CT-54
with: crm
owner: contracts
rule: "the draft_from_quote adapter lives outside the brick and calls contracts.draft; the brick holds the source_ref only"
# ---------------------------------------------------------------------------------------------
# Surfaces
# ---------------------------------------------------------------------------------------------
surfaces:
private:
app: "the composition's staff app behind the pool"
screens: [contracts, contract_editor, to_sign, clause_library, templates, contract_types, approvals, versions, certificate, signed_elsewhere, key_dates, settings]
documents: "versions and executed copies are streamed by use cases after the sensitivity check; the app's service account is the only bucket principal; no object link is ever handed out"
public:
app: >-
the platform's public-link door: a separate app with no pool (public_invoke true), its own narrow
service account, no PDF engine (it serves the frozen bytes), calling contracts' public use cases through a
seam scoped to one signer of one contract (an earlier client CRM's public-surface plan is the precedent)
screens: [signing_page, signed_copy]
stories: [CT-09, CT-10, CT-53]
requirement_on_the_door: "several tokens per subject, one per person per grant (a SIGN token and a signed-copy READ token for the same signer) — platform work H2b"
who_gets_a_link: "EXTERNAL signers only; our side signs inside the private app (CT-17)"
tokens:
- { purpose: sign, subject: "(contracts, contract_id, signer_no)", grants: [READ, SIGN], expires: "the link lifetime row", single_use: "SIGN once; READ until expiry" }
- { purpose: signed_copy, subject: "(contracts, contract_id, signer_no)", grants: [READ], expires: "a settings row (days)", note: "the executed copy, so a signer can download it (IWG principle)" }
rules: >-
one token per signer, never per document; stored as a hash; revocable as a column with who and when;
reissue revokes the previous one; seen (first_seen_at / last_seen_at / seen_count, and an OPENED event) kept
apart from acted (used_at, and the SIGNED / DECLINED event); an audit row per use; rate-limited; the page shows the
version, its sha256, the consent text and how long the signed copy is kept
screens:
- { id: contracts, name: Contracts, stories: [], actors: [owner, contract_admin],
note: "the list: state, counterparty, type, owner, next key date; employment bodies never shown without contract:employment:read" }
- { id: contract_editor, name: Contract editor, stories: [CT-05, CT-06, CT-08, CT-11, CT-14, CT-16], actors: [owner, contract_admin],
note: "from the earlier client CRM's contract page: header figures (state, signed, term), the sections as clause blocks (position pill, deviation level, overridden marker, 'add a clause'), the variables with their source and what is missing, the readiness panel whose items link to the fix, the signers panel (order up/down, side, title, seen vs acted, reissue, recall)" }
- { id: clause_library, name: Clause library, stories: [CT-01], actors: [contract_admin],
note: "from the earlier client CRM's clause library page: families with their Preferred / Alternate / Fallback clauses; a clause dialog with tabs Words · Usage (which contracts hold which version) · History (v3 current, v2 on 4 contracts, v1)" }
- { id: templates, name: Templates, stories: [CT-03, CT-04], actors: [contract_admin] }
- { id: contract_types, name: Contract types, stories: [CT-02], actors: [contract_admin] }
- { id: to_sign, name: To sign, stories: [CT-17], actors: [our_signatory],
note: "our side's signatories: the contracts waiting on them, the frozen PDF with its fingerprint, consent and sign; private, signed in" }
- { id: approvals, name: Approvals, stories: [CT-07], actors: [approver],
note: "the queue; each item shows the deviations against the standard and the fingerprint it binds to" }
- { id: versions, name: Versions, stories: [], actors: [owner, contract_admin],
note: "from the earlier client CRM's versions page: a rail (number, state ISSUED / SUPERSEDED / SIGNED, when, who) and a pane with the PDF and its sha256 on show; a sent version is locked, only superseded" }
- { id: certificate, name: Certificate, stories: [CT-13], actors: [owner, contract_admin],
note: "signers, every event with time, IP and user agent, the version hash, the executed-file hash, and the chain check (valid, or broken at seq n)" }
- { id: signed_elsewhere, name: Signed-elsewhere upload, stories: [CT-12], actors: [owner, contract_admin],
note: "from the earlier client CRM's upload dialog and record-files page: file, filename, where it was signed; downloads are streamed through the app, never an object link" }
- { id: key_dates, name: Key dates, stories: [CT-15], actors: [owner, contract_admin] }
- { id: signing_page, name: Signing page, stories: [CT-09, CT-10], actors: [signer], surface: public,
served_by: "the platform's public-link door app (no pool)",
note: "PUBLIC, READ + SIGN: the frozen PDF, its fingerprint, the consent text, the typed name, sign / decline / ask for changes, and how long the copy is kept; no script needed to sign" }
- { id: signed_copy, name: Your signed copy, stories: [], actors: [signer], surface: public,
served_by: "the platform's public-link door app (no pool)",
note: "PUBLIC, READ: the executed copy and its certificate, for the signer to download" }
- { id: settings, name: Settings, stories: [], actors: [contract_admin],
note: "every config_row below lives here" }
permissions:
namespace: contract
catalog:
- contract:type:write
- contract:clause:write
- contract:template:write
- contract:template:approve
- contract:contract:read
- contract:contract:read_all
- contract:employment:read
- contract:contract:write
- contract:contract:send
- contract:contract:recall
- contract:contract:upload_executed
- contract:contract:sign
- contract:approval:decide
- contract:evidence:read
- contract:amendment:write
- contract:settings:write
- contract:capability:invoke
- contract:run:tick
row_visibility:
rule: >-
an owner sees the contracts they own; contract:contract:read_all sees every COMMERCIAL contract; a contract
whose type is employment-sensitive (it holds pay) shows its body, variables, versions, executed copy and
evidence only to contract:employment:read (hr_admin-grade, finance) — anyone else, including its owner without
that permission and a manager, sees its existence, state and key dates and gets 404 on the rest; an approver
sees the drafts in their queue; our signatory sees contracts they sign; a calling module sees state and key dates
of what its source_refs caused, and only the ORIGINATOR receives the executed copy (contracts.status object key +
sha256, contracts.fetch_executed); an external signer sees one version through the door
enforced: "inside every use case from `caller` and the type's sensitivity; the same rule serves API, MCP and the provided capabilities"
not_found_not_forbidden: true
config_rows:
- { table: ct_type, changes: "your contract types: who they may be with, whether they can be signed online, how long they are kept, who approves deviations" }
- { table: ct_clause, changes: "your clause families and their Preferred, Alternate and Fallback wording" }
- { table: ct_template_version, changes: "your templates: which clauses in which order, and the fields each needs" }
- { table: ct_variable_def, changes: "each field: its type, where it comes from, whether it is required, its bounds (a day-rate band, a minimum notice)" }
- { table: ct_approval_policy, changes: "which deviation needs whose approval, the fallback approver and the timeout" }
- { table: ct_reminder_cadence, changes: "how often unsigned signers are chased, and the key-date reminders (120/90/60/30 days)" }
- { table: ct_consent_text, changes: "the words a signer agrees to before signing electronically, versioned" }
- { table: ct_retention_rule, changes: "how long each type's contracts and evidence are kept, and how long dead drafts linger" }
- { table: ct_settings, changes: "link lifetime, signed-copy link lifetime, our default signatories per type, whether an author may approve their own template" }
in_code_not_rows:
- "the lifecycle and who may move it; ACTIVE and EXPIRED derived"
- "that the counterparty is always a bp_id with an allowed role"
- "that a contract copies clause versions and freezes them; the library never alters a contract"
- "that a version is frozen at send with its sha256 and every signature binds to it; a stale version is refused"
- "that an approval binds to a fingerprint and an edit voids it; a fallback approver is mandatory"
- "the hash chain and its verification; events are append-only"
- "that SIGNED needs a complete set of events plus an executed copy, and an upload is SIGNED_ELSEWHERE"
- "that DEED / esign_allowed=false types can never be sent for online signing"
- "the claim: 'signed electronically', never advanced, qualified, legally binding or equivalent to a wet signature"
- "that employment-sensitive bodies need contract:employment:read"
- "that no provider for mail.send is a manual task with the exact input, and copying a link reissues it"
- "the UK pack checks (ERA s.1 day-one particulars, PRINCIPAL_STATEMENT_DUE, MEDIUM_LARGE + OUTSIDE needs an SDS)"
- "money as exact decimals; a money variable carries its currency"
tests:
acceptance: "one per story id above, named for it (test_ct_09_stale_version_is_refused, …)"
access:
- "a caller without contract:employment:read gets 404 on the body, variables, versions, executed copy and evidence of an employment contract, including its owner and a manager, through API, MCP and contracts.status"
- "an owner opening another owner's commercial contract without read_all gets 404"
- "an owner cannot decide an approval on their own draft"
- "a signer token reaches only its own signer's page; READ cannot SIGN; a signed-copy token cannot sign"
- "the calling module sees only contracts its source_refs caused"
- "a request with no gateway identity is rejected; a forged X-Dodil-User header is stripped"
invariants:
- "a retried contracts.draft with the same source_ref|type_code makes one contract; the same key with different inputs is 409"
- "a sent version's sha256 never changes; a re-send is version N+1"
- "the certificate and every page say 'signed electronically' and never 'advanced', 'qualified', 'legally binding' or 'wet signature'"
- "the executed copy is a real file whose sha256 matches its bytes"
- "every SIGNED contract has a SIGNED event per signer of its signed version; every SIGNED_ELSEWHERE one has an uploaded copy and no SIGNED event"
- "IP and user agent are fields on every public event"
live: "scripts/live_check.py: draft → send with no mail provider (manual link) → sign through the door → executed copy and certificate on a dev bucket"
scenarios:
- id: SC-CT-01
name: a clause deviation needs approval
stories: [CT-06, CT-07, CT-08]
given: a draft with the Preferred liability clause and no deviations
when: the owner switches it to the Fallback position and tries to send
then: send is refused ("approval by legal needed, liability cap is a Fallback"); an approval request for the current fingerprint appears in legal's queue; after approval the send succeeds
- id: SC-CT-02
name: an edit after approval voids it
stories: [CT-07]
given: an approved draft with fingerprint F1
when: the owner changes one variable (fingerprint F2) and tries to send
then: the approval is void, the draft is back in IN_APPROVAL for F2, and send is refused until F2 is approved
- id: SC-CT-03
name: a variable outside its band
stories: [CT-03, CT-07, CT-50]
given: a freelance template whose day_rate band is 300–600 GBP, and hr sending 750
when: contracts.draft runs
then: the draft is created with deviations [day_rate above band] and approval_required true; hr's run waits on the status; without approval send is refused
- id: SC-CT-04
name: an approval nobody answers
stories: [CT-07]
given: an approval with a 2-working-day timeout and a fallback group
when: the timeout passes undecided
then: it is escalated to the fallback group; the original approver can no longer decide it; the escalation is recorded
- id: SC-CT-05
name: a signer looks at a stale version
stories: [CT-09, CT-11]
given: version 1 out for signature; the starter opens the link; the owner recalls and sends version 2
when: the starter submits a signature from the page still showing version 1
then: refused ("this contract was re-issued, open the new link"); no SIGNED event for version 1; the version-1 token is revoked; the version-2 link works
- id: SC-CT-06
name: signing out of order
stories: [CT-09]
given: signing order starter (1) then director (2)
when: the director signs first
then: refused, naming who it waits on; after the starter signs, the director's signature succeeds
- id: SC-CT-07
name: a link expired, revoked or reused
stories: [CT-53, CT-30]
given: three signing links
when: one is used after its lifetime, one after a reissue, one after it already signed
then: all three get "this link no longer works, ask <owner>"; the door logs each refused use; no event is written to the contract except OPENED attempts recorded by the door; a contract past its lifetime unsigned becomes EXPIRED_UNSIGNED
- id: SC-CT-08
name: signed elsewhere
stories: [CT-12]
given: a DEED type contract
when: the owner tries to send it, then uploads the paper copy
then: send is refused (deeds are not signed online here); the upload makes it SIGNED_ELSEWHERE with the file's sha256; its signers are SIGNED_ELSEWHERE, never SIGNED; key dates exist
- id: SC-CT-09
name: amendment and renewal
stories: [CT-14]
given: a signed freelance agreement ending 2027-03-31
when: a RENEWAL to 2027-06-30 is drafted, approved where needed and signed
then: the original is SUPERSEDED, the renewal is SIGNED, END and NOTICE_DEADLINE move to the renewal's dates, and the original's evidence is unchanged and still verifies
- id: SC-CT-10
name: notice deadlines
stories: [CT-15, CT-30]
given: a supplier contract ending 2026-12-31 with 90 days' notice and reminders at 120/90/60/30
when: the clock passes 2026-06-04, 2026-07-04, 2026-08-03 and 2026-09-02
then: NOTICE_DEADLINE is 2026-10-02; one reminder at each of the four dates to the owner group; a second tick on a reminder date sends nothing more
- id: SC-CT-11
name: the hash chain tampered with
stories: [CT-13, CT-32]
given: a signed contract with 7 events
when: someone with bucket access edits the IP on event 4
then: the certificate's chain check reports "broken at seq 4"; verify_evidence fails; producing an executed copy for a contract in that state is refused
- id: SC-CT-12
name: no mail provider
stories: [CT-08, CT-52]
given: a composition with no mail.send provider
when: a contract is sent to two signers
then: two manual delivery tasks with the exact recipient and message; "copy the signing link" reissues the token and shows the link once; marking it delivered writes DELIVERED (manual); wiring a provider later changes nothing in the template or type
- id: SC-CT-13
name: missing variables
stories: [CT-05, CT-35, CT-50]
given: an employment template needing the ERA s.1 day-one particulars, and hr supplying no holiday entitlement
when: contracts.draft runs
then: missing [holiday_entitlement] is returned and no partial contract exists; a second call with it supplied drafts the contract
- id: SC-CT-14
name: IR35 refusal
stories: [CT-35]
given: FREELANCE_SERVICES with engagement_via PSC, client_size_regime MEDIUM_LARGE, ir35_determination OUTSIDE, no sds_issued_on
when: the owner sends it
then: refused in words; with sds_issued_on and sds_reference set it sends; with client_size_regime SMALL it sends without them
- id: SC-CT-15
name: principal statement due on day one
stories: [CT-35]
given: an employment contract with start_date 2026-11-02 still OUT_FOR_SIGNATURE on 2026-11-02
when: the tick runs that morning
then: the PRINCIPAL_STATEMENT_DUE key date is overdue; contracts.status warns hr ("the written statement has not been given and is due today"), because issued (sent) counts, not only signed
- id: SC-CT-16
name: both sides must be named
stories: [CT-08, CT-33]
given: a draft with only the counterparty as signer
when: the owner sends it
then: refused ("nobody on our side is named, so this cannot be complete"); readiness lists both_sides unmet
- id: SC-CT-17
name: decline and ask for changes
stories: [CT-10]
given: two contracts out for signature
when: one signer declines with a reason and another asks for changes
then: one is DECLINED and one goes back to DRAFT for a new version; every other token on each is revoked; the owner is told
- id: SC-CT-18
name: counterparty frozen at send
stories: [CT-51]
given: a contract sent with the partner's legal name "Studio Nine Ltd"
when: the partner is renamed in business-partner afterwards
then: the sent version and the executed copy still say "Studio Nine Ltd"; a new draft would show the new name
- id: SC-CT-19
name: a counterparty of the wrong role
stories: [CT-02, CT-50]
given: an EMPLOYMENT type allowing only the employee role
when: a draft is requested for a partner holding only the supplier role
then: refused ("an employment contract needs a partner with the employee role")
- id: SC-CT-20
name: employment bodies are sensitive-grade
stories: [CT-13]
given: a signed employment contract
when: a manager, an owner without contract:employment:read, and HR each open it through the API and MCP
then: the first two see its state and key dates and get 404 on the body, versions, executed copy and certificate; HR sees everything
- id: SC-CT-21
name: retention and erasure
stories: [CT-34]
given: a cancelled draft 40 days old (window 30), an executed simple contract ended 6 years and 1 day ago, a deed ended 7 years ago, and an erasure request for a signer of a live contract
when: the sweep runs and the erasure is applied
then: the draft is purged; the simple contract and its evidence are purged; the deed is kept (12 years); the live contract's evidence is kept under the legal-claims exception and the reason is recorded
- id: SC-CT-22
name: double send and double sign
stories: [CT-08, CT-09, CT-50]
given: a ready draft
when: send is clicked twice and a signer double-clicks sign
then: one version, one set of tokens, one SIGNED event per signer
- id: SC-CT-23
name: the whole thing through the clock
stories: [CT-05, CT-07, CT-08, CT-09, CT-31, CT-30]
given: hr's onboarding run asks for a freelance agreement with a rate above band, no mail provider, demo clock in-process
when: the clock moves through draft, approval, send, the starter's signature on day 1, a reminder to the director on day 4, the director's signature on day 5
then: each state appears at its time; the director signs inside the app (INTERNAL, POOL_SESSION); the executed copy with its certificate is produced once on day 5; contracts.status reports it to hr; hr fetches it with contracts.fetch_executed and files it on the person
- id: SC-CT-24
name: only the originator gets the executed copy
stories: [CT-50]
given: an employment contract created by hr (source_ref hr:onboarding/…) and signed
when: hr, then crm's adapter identity (another consumer holding contract:capability:invoke), call contracts.status and contracts.fetch_executed
then: hr receives the object key, the sha256 and the bytes (whose sha256 matches); crm gets executed = null from status and 404 from fetch_executed; neither path needs contract:employment:read
- id: SC-CT-25
name: our side signs in the app, not by link
stories: [CT-17, CT-08]
given: a contract with an EXTERNAL signer (the starter) and an INTERNAL signer (a director)
when: it is sent
then: one public link is issued (the starter's); the director has none and signs from To sign as their pool identity; a SIGN call through the door naming the director's signer_no is refused; the certificate shows EMAIL_LINK for the starter and POOL_SESSION with the pool sub for the director
provides_capabilities: # PROPOSED entries for erp-platform/capabilities.yaml (to be added there after the platform work)
contracts.draft:
summary: Draft a contract of a type for a counterparty from the approved template, filled from the values given.
input: {type_code: str, counterparty_bp_id: int, context_ref: "str|null", source_ref: str, variables: "map of name → value", signers: "list of {side: US|THEM, full_name, email, title, authority_basis, signing_order}|null"}
output: {contract_id: str, state: "DRAFT | IN_APPROVAL", missing: "list of variable names", deviations: "list of {what, level, needs: approver group}", approval_required: bool}
destructive: false
notes: >-
idempotency_key MUST equal '<source_ref>|<type_code>' (contract_id is derived from it); the same key with
different inputs is 409. Missing required variables return missing[] with status failed retryable=false and NO
partial contract. The caller supplies values, never clause text. A counterparty without a role the type allows is 422.
contracts.send:
summary: Freeze the current draft as a new version and send it for signature.
input: {contract_id: str}
output: {contract_id: str, state: OUT_FOR_SIGNATURE, version: int, sha256: str, deliveries: "list of {signer_no, channel: provider|manual}"}
destructive: false
notes: >-
Not undoable once delivered (recall revokes the links, it does not unsend mail). Refused (failed, retryable=false,
message in words) when missing[] is non-empty, an approval is open, the type is a deed or esign_allowed=false, both
sides are not named, or a UK pack check fails. dry_run returns the version that would be frozen and the deliveries.
contracts.status:
summary: Where a contract stands.
input: {contract_id: str}
output: {contract_id: str, state: str, version: "int|null", signers: "list of {signer_no, side, status, seen: bool}", executed: "{object_key, sha256, provenance}|null", key_dates: "list of {kind, due_on, done}", principal_statement_issued_at: "iso datetime|null", warnings: "list of str"}
destructive: false
notes: >-
A read, like drive.transfer_status — no idempotency claim. `executed` is returned only to the ORIGINATOR (the
caller whose contracts.draft created it, matched by the source_ref namespace); for anyone else it is null and
bodies are never returned.
contracts.fetch_executed:
summary: Stream the executed copy of a contract the caller created.
input: {contract_id: str}
output: "the file's bytes (application/pdf) with its sha256 in a header; never an object URL"
destructive: false
notes: A read. Allowed ONLY to the originator (source_ref namespace = the caller's brick); any other caller gets 404. This is what lets hr file the signed contract on the person without a broad contract:employment:read.
localisation_packs:
gb:
variables:
employment: "the ERA 1996 s.1 day-one particulars as REQUIRED variables: employer and employee names, job title or description, start date and continuous-employment date, pay and its interval, hours and days and how they vary, holiday entitlement, place of work, expected duration or fixed-term end, probation, benefits, obligatory training; sick pay, other paid leave and notice (these three may be in a separate accessible document, named by a variable)"
freelance: "engagement_via (SOLE_TRADER | PSC | UMBRELLA | AGENCY), intermediary_name, intermediary_company_no, client_size_regime (SMALL | MEDIUM_LARGE | PUBLIC; source CALLER: hr supplies it in contracts.draft from its settings per legal entity, and contracts keeps NO copy), ir35_determination (INSIDE | OUTSIDE | NOT_CLIENT_DECISION), sds_issued_on, sds_reference, substitution_allowed, day_rate or fixed_fee; they choose alternatives (PSC warranty and indemnity, substitution)"
key_dates: ["PRINCIPAL_STATEMENT_DUE = start_date", "SDS_REVIEW (when a determination is recorded)"]
checks: ["send refused for MEDIUM_LARGE + OUTSIDE without sds_issued_on", "an IP assignment needs the assignor as a THEM signer (CDPA 1988 s.90(3))", "DEED types are refused for online signing"]
retention: "term + 6 years (Limitation Act 1980 s.5); deeds 12 years (s.8)"
customer_varies:
- question: Are you content for contracts to be signed electronically with a link and a typed name?
changes: which types have esign_allowed, and whether a stronger method is needed (an extension point)
standard: >-
Electronic Communications Act 2000 s.7 (an electronic signature is admissible); UK eIDAS (retained
Regulation (EU) No 910/2014) art. 25 (not denied legal effect solely because it is electronic) and art. 26
(what an ADVANCED signature needs, which this is not); Law Commission, Electronic execution of documents
(Law Com No 386, 2019). This module gives a simple electronic signature with its evidence and says only
"signed electronically".
- question: Do you sign anything as a deed, or that needs a witness?
changes: DEED types (esign_allowed = false; signed elsewhere and uploaded)
standard: "Law Com No 386: its view is that a witness to a deed must be physically present"
- question: Do your freelancers assign their intellectual property to you?
changes: the IP clause family and that the assignor must sign
standard: "Copyright, Designs and Patents Act 1988 s.90(3): an assignment of copyright is not effective unless in writing signed by or on behalf of the assignor"
- question: Must every employment contract carry the day-one written statement?
changes: the employment type's required variables and the PRINCIPAL_STATEMENT_DUE key date
standard: "Employment Rights Act 1996 s.1 (the principal statement on or before the first day; the wider statement within two months)"
- question: For freelancers through their own companies, how big is your business?
changes: "the IR35 send check; the size itself is hr's setting per legal entity, passed to contracts.draft as client_size_regime (contracts keeps no copy; non-hr contract types never use it)"
standard: "Income Tax (Earnings and Pensions) Act 2003 Part 2 Chapter 10 (off-payroll working) and HMRC's size tests (ESM10006); the thresholds rose from 6 April 2025, so check the current figures"
- question: How long do you keep contracts after they end?
changes: ct_retention_rule
standard: "Limitation Act 1980 s.5 (six years for a simple contract) and s.8 (twelve for a deed); UK GDPR art. 5(1)(e) for the personal data in the evidence"
typical:
most_businesses: the term plus six years for signed contracts, a month or two for dead drafts
- question: Who signs for you, and who approves anything off the standard?
changes: our default signatories per type, approval policies and the fallback approver
typical:
small_agency: a director signs; operations approves a rate outside the band, a director anything legal
- question: How often should we chase an unsigned signer, and how long is a link good for?
changes: ct_reminder_cadence and the link lifetime
typical:
common: a reminder every two or three working days, a link valid for 14 to 30 days
- question: Do you sign the other side's paper sometimes?
changes: whether SIGNED_ELSEWHERE uploads are routine for some types
extension_points:
- { name: signature_provider, what: "an interface for stronger methods: an email one-time code first, then advanced or qualified signatures through a trust-service provider for high-risk types" }
- { name: propose_changes_inline, what: "a counterparty proposing edits to the text, not only a note (IWG principle)" }
- { name: obligations, what: "obligations beyond dates (deliverables, payments) tracked against the contract" }
- { name: trusted_timestamps, what: "RFC 3161 timestamps on version and executed hashes" }
- { name: multi_language, what: "a template version per language" }
reference:
# Public repos, anonymous clone, one release tag each.
repos:
- name: brick-contracts
role: brick
https: https://git.dodil.io/dodil/brick-contracts.git
ssh: git@git.dodil.io:dodil/brick-contracts.git
tag: v0.1.0
read_first: [README.md, AGENTS.md]
- name: brick-business-partner
role: brick
https: https://git.dodil.io/dodil/brick-business-partner.git
ssh: git@git.dodil.io:dodil/brick-business-partner.git
tag: v0.3.0
read_first: [README.md, AGENTS.md]
- name: brick-hr
role: brick
https: https://git.dodil.io/dodil/brick-hr.git
ssh: git@git.dodil.io:dodil/brick-hr.git
tag: v0.4.1
read_first: [README.md, AGENTS.md]
- name: erp-platform
role: platform
https: https://git.dodil.io/dodil/erp-platform.git
ssh: git@git.dodil.io:dodil/erp-platform.git
tag: v0.5.3
read_first: [README.md, AGENTS.md]
packages: []
contracts: []
build_guide: null
known_gaps:
- "not deployed: it has not run behind the gateway for a real person, so the self-check through ignite invoke and a sign-in per role are still to do"
- "no screen (H5): API and MCP only; the signing pages are served by the composition's public app"
- "a simple electronic signature only; advanced or qualified signatures are the signature_provider extension point, not built"
- "not built: inline change proposals, obligations beyond dates, RFC 3161 timestamps, multi-language templates"
- "executed copies are bytes in ct_object_chunk in the bucket until the platform has an object-store helper"
- "the platform's seam does not say which brick is calling, so the originator is matched by ct_consumer rows (sub, role or email)"
- "use cases receive no client IP, user agent or session id: our side's events carry the pool sub but no IP, agent or session"
- "a contracts.draft with missing values answers 422 (claim released), not a stored failure, because a stored failure would make the corrected call a 409"
- "hr does not poll a contract that needed approval (tests/test_tier1.py xfail)"