Lifecycles — the state machines
Confidence markers and citation shorthand per README.md. Everything here describes the Lovable MVP at origin/main (ffab85ca); monet ports the UI shells but almost none of these transitions execute against the real backend yet (see 05-implementation-state.md).
1. Capacity and reservation lifecycle
A reservation is a ledger, not a balance. Every grant, top-up and release is its own programme_reservations row with its own expiry; nothing is ever merged or silently shrunk. VERIFIED (L:src/pages/admin/AdminProgrammes.tsx:56-62,553-616; the old one-row-per-company unique index was deliberately dropped, origin/main SQL 20260816205544).
The flow: a participant requests capacity (AddReservationModal writes reservation_requests, submit disabled at CU <= 0, with the CU estimator feeding the amount) VERIFIED (L:src/components/AddReservationModal.tsx:158,167-190); FT Admin approves (inserts the real reservation row, throws if CU <= 0, links back via approved_reservation_id, notifies) or rejects with a note VERIFIED (L:src/pages/admin/AdminReservationRequests.tsx:97-135). Admin can also adjust directly on the programme panel, where negative values are allowed (“Use a negative value to subtract. 1,000 CU = 1 MT.”) VERIFIED (AdminProgrammes.tsx:1463).
DB guards, final form: quantity must not be zero; a positive insert must not push the programme’s unexpired reserved total past capacity (“Reservation exceeds remaining programme capacity”); a negative insert (release) must not exceed the company’s active reserved capacity. VERIFIED (origin/main SQL 20260818202335:11-43; 20260421164336:70-71). Expiry: a BEFORE trigger defaults expires_at = created_at + default_reservation_term_months (programme default 18); expired rows are excluded from every usable-capacity aggregate but kept as history, and negative releases reuse the latest live expiry so history shows the release. VERIFIED (origin/main SQL 20260816203909:11-26; L:src/hooks/useProgrammes.ts; AdminProgrammes.tsx:588-616).
The funnel the participant sees on /claim-position: Ceiling = sum of producer ceilings; Activated = claimed + available (production-driven); Reserved = unexpired reservation total; Available to claim = min(my reserved - my claimed, total produced - total claimed), floored at 0; Pending = max(0, reserved - activated). VERIFIED (L:src/hooks/useProgrammes.ts; L:src/pages/ClaimPosition.tsx:474-478). The real backend serves this as the 6-stage claim-positions funnel; its internal formulas are the server’s own and not independently confirmed. STATED (.local/backend-specs/fibre-program/README.md:24; UNKNOWN whether server formulas match the client’s exactly).
2. The reservation -> nomination -> claim chain
The intended invariant is reserved >= nominated >= claimed. STATED (QC:11 — the first formal statement of the three-way rule anywhere). What the code actually enforces differs by leg. Nominations are only softly bounded: the Claim Position page computes PO-raised volume and shows “Near limit” at 90 percent and “Over limit” past the reservation — a warning, not a block. VERIFIED (L:src/pages/ClaimPosition.tsx:387-398,565-586). Claims are hard-bounded twice at the DB: a claim may not exceed the company’s remaining unexpired reservation, nor the programme’s unclaimed produced volume. VERIFIED (validate_production_claim_limits, SQL 20260421164336:123-129, expiry-aware since origin/main 20260816203909:59). And claim proposals are additionally bounded in the UI at min(buyer’s available capacity, programme remaining). VERIFIED (L:src/components/LinkVerificationsToPoModal.tsx:236-280). So the middle inequality (nominated <= reserved) is advisory today; treating it as a hard rule is a QC expectation the code does not enforce. VERIFIED divergence.
3. Nomination (purchase order) lifecycle
Status enum in creation order: draft, shared, linked, ready, proposed, partially_claimed, fully_claimed, closed (proposed inserted 2026-06-18; closed added 2026-08-14). VERIFIED (SQL 20260608132010:8; 20260618105531:35; 20260814091422:1). Observed transitions: creation inserts directly as shared (draft exists in the enum but the create hook skips it) VERIFIED (L:src/hooks/usePurchaseOrders.ts:118-149); linking a verification bumps to ready via the DB trigger (which also blocks linking on fully_claimed/closed POs and enforces the partner-facility-or-shared rule) VERIFIED (validate_po_verification, SQL 20260608132010); proposing sets proposed VERIFIED (usePurchaseOrders.ts:373-560); confirming sets partially_claimed or fully_claimed depending on line completion (evidence-pack insert trigger meters volume and recomputes) VERIFIED (apply_po_evidence_pack; usePurchaseOrders.ts:640-746); rejecting reverts to shared and deletes the PO’s verification links VERIFIED (usePurchaseOrders.ts:773-834); the retailer’s close requires every non-excluded line fully claimed unless allowShort and is immutable VERIFIED (usePurchaseOrders.ts:1023-1059). Orthogonal flags: archived (hides from active views, frees the po_reference uniqueness), supplier_closed_at/note (manufacturer’s “no more shipments” signal, does not change status), linking_due_at (overdue signal). VERIFIED (schema; usePurchaseOrders.ts:1063-1090).
4. Claim lifecycle — proposal, review, approval, revocation
A claim is born proposed (manufacturer’s act), scoped to the retailer company, with an allocation snapshot and session bindings; a DB trigger rejects a proposed claim with zero linked verifications. VERIFIED (usePurchaseOrders.ts:373-560,437-441; validate_claim_has_verifications, origin/main SQL 20260814070749). The retailer reviews (single or bulk): confirm flips to confirmed, creates the evidence pack rows, commits line quantities and seeds public tokens for trade/sustainability/procurement; reject stores reason/timestamps and frees the manufacturer’s scans. VERIFIED (usePurchaseOrders.ts:640-834,722-732). Bulk approval buckets unchanged (auto-selected) vs to-review proposals, computes over-capacity groups with a greedy keep-smallest “Deselect to fit” recommendation, and confirms sequentially so one failure does not roll back earlier confirms. VERIFIED (BulkClaimApprovalModal.tsx:88-183). Post-confirmation, both parties are locked out of edits. STATED (BL:wt2:30, consistent with no edit mutations existing).
Two admin-only exits: revoke (status revoked + revoked_at/by/reason — certificate invalidated, CU deliberately NOT released, public page shows a Revoked banner) and void (full delete of claim, pack, session bindings and PO links — CU and scans freed, PO volume recomputed). VERIFIED (usePurchaseOrders.ts:936-1008; PublicClaim.tsx:69-96). Caution: the DB CHECK on production_claims.status was found as (proposed, confirmed, rejected) while the app writes revoked — either a later migration widened it or revoke fails at the DB; unresolved. INFERRED risk (SQL 20260618105531:2-24 vs 20260811125616; see 06-open-questions Q10).
The DB meters every claim insert/update against remaining unexpired reservation and unclaimed produced volume (see section 2). Claim references render as CLM- plus the first 8 hex of the id. VERIFIED (useEvidencePackData.ts:36).
5. Scan / verification lifecycle — obfuscation and retirement
A scan is born in the field: SAT pairs over BLE, sends SCAN_COMMAND, parses tau, verifies online against per-FTID tolerance windows (or offline against the cached list), and posts the audit with geolocation; offline audits queue in IndexedDB and background-sync later. VERIFIED (apps/sat: fibreReader.ts, AuditWizard.tsx:70-155, offlineQueue.ts). On the dashboard the scan lives inside a session at a facility, optionally paired to a counterpart session by shipping_doc_id (chain of custody), optionally shared to another company (session_shares), and auto-associated to a programme when it lands at a registered producer facility inside the window (raising produced_mt, capped at ceiling). VERIFIED (schema; auto_associate_session_to_programme).
Identity masking: by default (app_settings obfuscate_verification_ids = ON) the session code, FT ID and blockchain GUID are masked at the database layer — the sessions_v/audits_v views apply mask_ftid() (first 4 + bullets + last 4) unless the session is linked into a confirmed claim, and the raw base columns are REVOKEd from client roles. The UI renders the mask with copy/drag disabled and an Inert connection chip; once claim-linked the chip flips to “Linked & Verified by {company}” and IDs unmask. VERIFIED (origin/main SQL 20260717072757:78-158, 20260806124352; ObfuscatedId.tsx:48-90). This resolves the older open question about where masking lives: in the Lovable MVP it is server-side (in Supabase); what the real Laravel backend will do (the asked-for session_code_masked field) is still open. VERIFIED for Lovable; UNKNOWN for real BE.
Retirement: linking is exclusive at the DB — a session can back at most one PO ever (UNIQUE), one programme association (UNIQUE), one manual claim link (UNIQUE), and one claim per company. After the claim confirms, the session is locked in the UI (is_locked = PO-linked AND pack exists). Rejection or admin void releases it. Malcolm’s tier-scoped retirement rule — only the Tier 1’s chosen scans retire, sub-tier scans never do — is the business intent this implements, since sub-tier scans simply are not linked to the claim. VERIFIED constraints (SQL uniques; useVerifications.ts:209-234) + STATED intent (BL:wt2:28-29).
6. Certificate and evidence-pack lifecycles
Programme certificates (owner-uploaded): created with expiry metadata, status active or revoked (DB CHECK); expired items are flagged in the owner UI; assignment to programmes via program_certificate_assignments makes them visible in participant evidence. VERIFIED schema/UI (SQL 20260606025632:77; owner pages) — participant-side rendering end-to-end is INFERRED. Company certificates (Settings > Certificates): valid_from/valid_to windows, all-facilities or specific-facility scoping, attachable to claims during proposal and PO-scoped via po_scope_certificates (one certificate per PO). VERIFIED schema (company_certificates, po_scope_certificates). Claim-attached certificates: Malcolm’s model is 1:N per transaction; the exact sub-tier inheritance model was unresolved at the demo and remains so. STATED (BL:demo:15-17,116).
Evidence packs: created only as a side-effect of claim confirmation (never directly), metered by the apply_po_evidence_pack trigger (pack volume cannot exceed PO remaining; recomputes claimed volume and status; is_partial flag). Eight pack types exist as a code-side registry with per-pack sections and shareability; three (trade, sustainability, procurement) get public tokens seeded automatically. A pack’s public page honours token enabled/disabled and shows a Revoked banner when the claim was revoked; packs themselves never display partial/full status (deliberate — irrelevant externally). VERIFIED (SQL trigger; evidencePacks.ts:15-24; usePurchaseOrders.ts:722-732; PublicClaim.tsx:69-96) + STATED for the no-partial-display rationale (BL:wt2:37). Admin revoke invalidates the certificate/pack without releasing CU; admin void deletes pack and claim and frees everything. VERIFIED (usePurchaseOrders.ts:936-1008).