"Mostly done" as an answer. With this you can say the screens lift 80% of the model, here are the 3 named gaps, and one journey has no UI at all.
Add "Product intent model" from Groundwork to this repo.
Fetch https://ntent.app/r/f/contract as plain text. Write it verbatim to contract.json. Then make exactly these edits and no others: It is an example billing product. Keep the shape and replace the actors, entities, journeys, rules, constraints and open questions with this product’s own. Delete the example acceptance records: they describe journeys you have not built.
Then check it: The coverage check runs and prints a number, and `--refs` says the references resolve. Start with entities and journeys; add a pillar when you can name the screen it changes. Mark a journey built only with its acceptance record beside it.Reads https://ntent.app/r/f/contract
Read the code
contract.jsoncontract.json
261 lines
{
"version": "0.4.0",
"updated": "2026-09-05",
"_comment": "The product contract. Eight pillars: actors, access, entitlements, entities, journeys, rules, constraints, open questions. Everything here either feeds a gate or feeds a screen. Start with entities and journeys, add a pillar when something forces you to, and run scripts/check-contract-coverage.mjs to score it. A journey is built when it carries an acceptance record: who exercised it, when, at which commit, against what evidence. The reference check refuses built without one. Worth doing at Tier 2 and up.",
"actors": [
{
"id": "finance_admin",
"description": "Staff who issue and void invoices. Internal.",
"auth": "SSO, staff directory",
"responsibilities": ["issue an invoice", "void and reissue an invoice", "refund a payment"]
},
{
"id": "customer",
"description": "The billed party's contact. External, scoped to one customer record.",
"auth": "email magic link",
"responsibilities": ["view invoices", "pay an invoice", "download a receipt"]
},
{
"id": "billing_webhook",
"kind": "system",
"description": "Stripe. Tells us a charge settled or failed. No screen, and it can arrive twice.",
"auth": "signed webhook secret",
"responsibilities": ["mark an invoice paid", "start dunning on a failed charge"]
},
{
"id": "dunning_job",
"kind": "schedule",
"description": "Nobody triggers it and it has no screen, which is exactly why its behaviour has to be written down.",
"schedule": "daily 02:00 UTC",
"responsibilities": ["move issued invoices past due_date to overdue", "send a reminder on day 3, 7 and 14"]
}
],
"access": {
"tenancy": {
"scope_entity": "customer",
"rule": "Every invoice, subscription and payment row carries customer_id. A customer actor reads only rows whose customer_id is their own. There is no cross-customer read at any layer.",
"staff_exception": "finance_admin reads across all customers. Every such read is written to the audit log with the actor and the row id."
},
"matrix": [
{
"actor": "finance_admin",
"entity": "invoice",
"can": ["read", "create", "void"],
"cannot": ["update"],
"why": "BR-014. An issued invoice is the legal record, so correcting one means voiding and reissuing."
},
{ "actor": "customer", "entity": "invoice", "can": ["read", "pay"], "scope": "own" },
{ "actor": "customer", "entity": "payment", "can": ["read"], "scope": "own" },
{ "actor": "billing_webhook", "entity": "payment", "can": ["create"], "scope": "all" }
]
},
"entitlements": {
"plans": ["free", "team", "business"],
"gates": [
{
"id": "ENT-001",
"capability": "custom_invoice_branding",
"type": "feature",
"plans": ["business"],
"gates": ["journey:create-invoice"],
"locked_ui": "The branding panel renders with an upgrade CTA where the controls would be. Not hidden: a feature nobody can see is a feature nobody upgrades for."
},
{
"id": "ENT-002",
"capability": "seats",
"type": "quota",
"limits": { "free": 1, "team": 10, "business": null },
"gates": ["journey:create-invoice"],
"at_limit": "Invite button disabled, tooltip naming the count and the plan ceiling. See OQ-009 for what a seat removal does."
}
]
},
"entities": [
{
"id": "invoice",
"description": "A bill sent to a customer. Immutable once issued.",
"fields": [
"invoice_number",
{ "name": "total_amount", "type": "money", "required": true },
{ "name": "customer_id", "type": "id", "required": true },
"created_at",
{ "name": "due_date", "type": "date", "required": true },
"line_items",
{ "name": "tax_rate", "type": "decimal" },
{ "name": "pdf_url", "type": "url", "note": "Written by a worker after issue. Absent on a draft, and briefly absent on a fresh issue." }
],
"states": ["draft", "issued", "paid", "overdue", "void"],
"notes": "A field may be a bare string or an object. Add the object form when a type, a required flag or a PII marker starts earning its keep."
},
{
"id": "customer",
"description": "The billed party. One per Stripe customer.",
"fields": [
"customer_id",
{ "name": "name", "type": "text", "required": true, "pii": true },
{ "name": "email", "type": "email", "required": true, "pii": true },
{ "name": "billing_address", "type": "address", "pii": true, "retention": "7y" }
]
},
{
"id": "subscription",
"description": "An active plan. Seat changes take effect immediately, bill at period end.",
"fields": [
{ "name": "plan", "type": "enum", "values": ["free", "team", "business"], "required": true },
{ "name": "seats", "type": "integer", "required": true },
{ "name": "renews_at", "type": "date" }
],
"source": "DECISIONS.md 2026-08-30, seat proration"
},
{
"id": "payment",
"description": "A settled charge against an invoice.",
"fields": [
{ "name": "amount", "type": "money", "required": true },
{ "name": "method", "type": "enum", "values": ["card", "bank_transfer"] }
]
}
],
"journeys": [
{
"id": "create-invoice",
"actor": "finance_admin",
"entities": ["invoice", "customer"],
"depends_on": ["BR-014"],
"state": "partial",
"gap": "the seat-quota state ENT-002 puts on this screen waits on OQ-009. Everything else is built."
},
{
"id": "send-invoice",
"actor": "finance_admin",
"entities": ["invoice"],
"state": "partial",
"gap": "no email preview, no resend. BR-014 requires both."
},
{
"id": "pay-invoice",
"actor": "customer",
"entities": ["invoice", "payment"],
"state": "built",
"accepted": {
"on": "2026-09-04",
"by": "R. Nair, finance lead, with the designer",
"commit": "8c1f2a9",
"contract": "0.4.0",
"evidence": [
"pnpm test -- pay-invoice",
"manual: paid a real-shaped invoice on a phone, through the card-declined path and the retry. C-002."
]
}
},
{
"id": "settle-charge",
"actor": "billing_webhook",
"entities": ["payment", "invoice"],
"state": "built",
"accepted": {
"on": "2026-08-21",
"by": "M. Okafor, engineering",
"commit": "3b7d0e1",
"contract": "0.3.0",
"evidence": ["src/webhooks/settle.test.ts: the same charge twice records one payment. BR-030."]
}
},
{
"id": "chase-overdue",
"actor": "dunning_job",
"entities": ["invoice"],
"state": "unbuilt",
"gap": "the job runs nightly and sends nothing. No template, no send, no record of a send. It has no screen, which is why nobody noticed."
},
{
"id": "refund-payment",
"actor": "finance_admin",
"entities": ["payment", "invoice"],
"state": "unbuilt",
"gap": "entirely unbuilt. No UI, no route. Blocked by OQ-011."
}
],
"rules": [
{
"id": "BR-014",
"text": "An invoice can be resent to the same address any number of times, but changing the recipient requires voiding and reissuing, because the original is the legal record.",
"applies_to": ["invoice"],
"source": "Client call 2026-08-12. Finance lead: 'the address on the sent one has to be the address it went to.'",
"status": "agreed"
},
{
"id": "BR-021",
"text": "Seat additions take effect immediately and bill at the next period boundary. Plan upgrades prorate immediately.",
"applies_to": ["subscription"],
"supersedes": ["BR-007", "BR-018"],
"source": "DECISIONS.md 2026-08-30. Consolidates former BR-007 and BR-018.",
"status": "agreed",
"note": "Whether a seat REMOVAL credits at period end is an inference, not agreed. See OQ-009."
},
{
"id": "BR-030",
"text": "A settled-charge webhook may arrive more than once for the same charge. The second one must not create a second payment.",
"applies_to": ["payment", "invoice"],
"source": "Incident 2026-08-19. Two payments recorded against one invoice, and the invoice showed a credit balance to the customer.",
"status": "agreed"
}
],
"constraints": [
{
"id": "C-001",
"kind": "compliance",
"text": "Invoice PDFs are financial records. Retain 7 years and never hard-delete one, including on an erasure request. Redact the customer PII fields instead.",
"affects": ["invoice", "customer"]
},
{
"id": "C-002",
"kind": "platform",
"text": "Finance staff work on desktop at 1440 and up. Customers pay on phones. The customer-facing invoice view is the only screen that has to be responsive.",
"affects": ["journey:pay-invoice"]
},
{
"id": "C-003",
"kind": "capacity",
"text": "The dunning job has a 15-minute window and 40k open invoices. It has to batch and be resumable from a partial run.",
"affects": ["journey:chase-overdue"]
}
],
"open_questions": [
{
"id": "OQ-009",
"question": "Does removing a seat credit at the period end, or not at all?",
"blocks": ["ENT-002"],
"status": "open",
"blocking": true,
"asked_of": "Finance lead",
"asked_on": "2026-08-30"
},
{
"id": "OQ-011",
"question": "Can a refund be partial, and can one invoice be partially refunded more than once?",
"blocks": ["journey:refund-payment"],
"status": "open",
"blocking": true,
"asked_of": "Client finance",
"asked_on": "2026-09-02"
},
{
"id": "OQ-004",
"question": "Which address does a reissued invoice use when the customer record changed in between?",
"blocks": ["BR-014"],
"status": "resolved",
"blocking": false,
"resolution": "The address on the customer record at reissue time. DECISIONS.md 2026-08-14."
}
]
}
Success check: The coverage check runs and prints a number, and `--refs` says the references resolve. Start with entities and journeys; add a pillar when you can name the screen it changes. Mark a journey built only with its acceptance record beside it.