Fibretrace Monet docs/Knowledge base/Lifecycles

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).

Reservation ledger lifecycle reservation_requestpending (CU > 0 only) reservation rowactive, expires_at set expiredout of capacity, kept in history rejecteddecision_note kept negative row (release)reuses latest live expiry admin approve (CU > 0) admin reject expires_at < today (auto) admin subtract DB guards (trigger, final form 2026-08-18) quantity = 0 -> exception "must not be zero" positive: unexpired reserved + new <= programme capacity negative: |release| <= company's active reserved capacity expires_at := created_at + term months (default 18) origin/main SQL 20260818202335, 20260816203909 Direct admin grants on the programme panel skip the request queue entirely. The request queue never accepts negatives; releases exist only on the panel. QC A-04 expects negatives at the queue route — divergence, see 06-open-questions Q8.

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.

reserved >= nominated >= claimed — what actually enforces each leg RESERVEDunexpired programme_reservations NOMINATEDopen purchase_orders volume CLAIMEDconfirmed production_claims SOFT: "Over limit" warning only HARD: DB trigger blocks Soft leg: ClaimPosition PO-raised KPI warns at 90% ("Near limit") and past 100% ("Over limit") of reserved. No insert is blocked. Hard leg: validate_production_claim_limits — claim <= remaining unexpired reservation AND <= unclaimed produced volume. UI adds: proposal claim MT <= min(buyer available, programme remaining); bulk approval computes "Deselect to fit". Also upstream: sum of reservations <= programme capacity (ceilings), enforced on reservation insert.

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).

po_status state machine (retailer left, manufacturer middle, retailer right) draft shared linked ready proposed partially_claimed fully_claimed closed (unused) legacy link link trigger propose confirm (lines remain) confirm (all lines done) retailer close close (allowShort) reject: revert to shared, delete PO links propose remainder (partial claim loop) Creation inserts directly as "shared" (draft never used by the create hook). "linked" survives from the legacy pre-propose path. Orthogonal flags, any state: archived (hides + frees po_reference uniqueness) · supplier_closed_at (signal only) · linking_due_at (overdue). Link trigger also BLOCKS new links once fully_claimed/closed and enforces session facility = PO partner or shared session. closed is immutable; requires all non-excluded lines fully claimed unless allowShort.

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).

production_claims lifecycle + evidence side-effects proposedby manufacturer confirmedby retailer rejectedreason kept, scans freed revokedadmin; CU kept (voided)admin; deleted, CU freed confirm (bulk: sequential, no rollback) reject with reason admin revoke admin void manufacturer re-proposes on confirm:evidence pack rows created (metered vs PO remaining) · line quantity_claimed committed · PO -> partially/fully_claimed · public tokens seeded (trade, sustainability, procurement) · linked sessions lock and unmask ("Linked & Verified by {brand}") DB gates: proposed requires >= 1 linked verification · quantity <= remaining unexpired reservation AND <= unclaimed produced volume.

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).

Scan/session lifecycle — masked Inert to unmasked Linked field scan (SAT)tau match + geolocation session: INERTIDs masked (DB views) shared / pairedsession_shares, waybill pair PO-linkedone PO ever (UNIQUE) LINKED and LOCKEDunmasked, "Verified by {brand}" POST sat-audit link/propose claim confirmed reject / unlink / admin void -> back to INERT (mask restored) Masking rule (server-side in Supabase): visible = setting OFF, or session linked into a confirmed claim. mask_ftid: ABCD••••••WXYZ. Base columns REVOKEd from client roles; only sessions_v/audits_v readable. Exclusivity: one programme association, one PO link, one manual claim link per session; one claim per company per session. Sub-tier scans are never linked, so they never retire and never appear on the claim (Malcolm, walkthrough 2).

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).

Evidence pack + public token lifecycle claim confirmedthe only creation path pack rows (metered)volume <= PO remaining; is_partial public tokens seededtrade, sustainability, procurement /c/:token live (QR) disabled / revoked view enabled toggle off / claim revoked 8 pack types (registry, code-side): verification-record · sustainability · procurement · retailer · assurance · regulator · dpp · supplier. Certificates: programme-level (active/revoked + expiry, owner), company-level (validity window + facility scoping), claim-attached (1:N stated). Admin exits: revoke = certificate invalidated, CU kept, banner on public page. Void = pack + claim deleted, CU and scans freed. No outbound webhook exists anywhere; the "webhook" is a JSON preview page (tokenless — see 06-open-questions Q12).