contract.json

Product intent model

Tier 2productdesignengineering

"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 to your project
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.

Included in