Fibretrace Monet docs/Knowledge base/Rules + invariants

Business rules and invariants

Each rule carries its evidence and confidence. The six rules the QC script states come first (R-01 to R-06), followed by everything else the migrations, Lovable components, canvas and the walkthroughs encode. Citation shorthand per README.md; SQL: cites migrations (monet snapshot or origin/main as noted).

A. The six QC-stated rules, tested against the code

R-01 — 1 CU = 1 kg; CU = quantity x net weight x blend %. STATED (QC:6). The 1-kg identity and 1000 CU = 1 MT are VERIFIED everywhere (L:src/lib/claimUnits.ts CU_PER_MT = 1000; AdminProgrammes “1,000 CU = 1 MT”). The formula as stated is INCOMPLETE: the app computes CU per piece = net weight kg x blend/100 x loss multiplier, and the admin UI says so (“baseline weight x blend ratio x internal loss multiplier”). VERIFIED (claimUnits.ts:1-11; AdminProductCategories.tsx:144). Consequence: QC testers hand-computing qty x weight x blend will file false mismatches wherever loss_multiplier differs from 1 — and it is internal-only, so they cannot see it. See 06-open-questions Q4.

R-02 — Blend % comes from admin product-category configuration, never hard-coded. STATED (QC:7), VERIFIED as the default path: categories carry default_blend_pct (5 percent steps) and prefill PO lines and the CU estimator; per-line edits are permitted but flagged overridden and surfaced on the admin overrides report with CU impact. (AdminProductCategories.tsx; usePurchaseOrderLines.ts:11-24; AdminOverrides.tsx:48-84). Nuance: the programme create form has NO blend field at all, so QC A-01’s “set default blend %” step tests a field that does not exist. VERIFIED (AdminProgrammes insert payload; .local/qc-test-script/analysis.md e1).

R-03 — Fibre Producer only via producer registration; SDU/EWR is a feature gate, not a persona. STATED (QC:8), VERIFIED in the canonical hook (usePersonaContext isProducer = programme_producers rows only). The older usePersona hook still ORs in the sdu company module — retired by this rule but present in code. And the SDU Device Status nav item is gated on device count, not registration, so an SDU-only company still sees that one item, contrary to QC P-01’s expected result. VERIFIED divergence (usePersona.ts:34; AppSidebar.tsx:114-137).

R-04 — Scan IDs and blockchain GUIDs obfuscated by default; unmask only when claim-linked. STATED (QC:9), VERIFIED and stronger than stated: masking is server-side in Supabase — sessions_v/audits_v views apply mask_ftid() gated on NOT setting OR claim-linked, and raw base columns are REVOKEd from client roles; the toggle defaults ON. (origin/main SQL 20260717072757:78-158, 20260806124352; useObfuscateVerificationIds.ts:4). Open residue: what the real Laravel backend will do (session_code_masked was explicitly left unresolved). UNKNOWN (.local/backend-specs/fibre-program/README.md:54).

R-05 — Reservations default to 18-month expiry and may carry negative entries. STATED (QC:10), VERIFIED: expires_at := created_at + default_reservation_term_months (default 18) by BEFORE trigger; negative rows are releases that reuse the latest live expiry; zero is forbidden; a release cannot exceed the company’s active reserved capacity. (origin/main SQL 20260816203909:11-26, 20260818202335:11-43). Caveat: negatives are accepted ONLY on the programme panel — the reservation-request queue rejects CU <= 0 on both ends, so QC A-04 fails at the route it names. VERIFIED (AdminReservationRequests.tsx:101; AddReservationModal.tsx:158).

R-06 — Reserved >= nominated >= claimed must reconcile. STATED (QC:11 — the first formal three-way statement in any source). Enforcement is asymmetric: claimed <= reserved is HARD (DB trigger, expiry-aware); nominated <= reserved is SOFT (the ClaimPosition PO-raised KPI warns Near limit at 90 percent and Over limit past 100 percent, but nothing blocks the nomination); the UI additionally caps proposals at available capacity, and bulk approval computes a deselect-to-fit set. VERIFIED (SQL 20260421164336:123-129 + 20260816203909:59; ClaimPosition.tsx:565-586; LinkVerificationsToPoModal:236-280; BulkClaimApprovalModal:140-155). Treating the middle leg as hard is a QC expectation the code does not meet.

B. Capacity and reservations

R-07 — Total unexpired reservations per programme cannot exceed programme capacity (sum of producer ceilings). Trigger raises “Reservation exceeds remaining programme capacity”. VERIFIED (SQL 20260421164336:70-71; expiry-aware origin/main 20260816203909).

R-08 — A claim cannot exceed the company’s remaining unexpired reservation, nor the programme’s unclaimed produced volume. Trigger validate_production_claim_limits, matched on claim.company_id. VERIFIED (SQL 20260421164336:123-129).

R-09 — Expired reservation rows leave every usable-capacity figure but remain visible as history; the UI renders negative rows as “Released” and expired rows with an Expired badge. VERIFIED (useProgrammes.ts aggregate filters + myExpiredByProgramme; ClaimPosition.tsx:140-207).

R-10 — The capacity funnel definitions. Ceiling = sum producer ceilings; Activated = claimed + available (production-driven, from non-excluded production_records weight); Pending = max(0, reserved - activated); available_to_claim = max(0, min(my reserved - my claimed, total produced - total claimed)). VERIFIED (useProgrammes.ts; ClaimPosition.tsx:474-478). The server’s own funnel formulas are unconfirmed. UNKNOWN.

R-11 — Producer production is capped at the ceiling: produced_mt = LEAST(ceiling_mt, scans_completed / scans_per_mt), recomputed by the session-association trigger. VERIFIED (SQL 20260420114208:140-183). No client-side “exceeds allocation” error exists in FibreCreation, contrary to QC P-06. VERIFIED absence.

R-12 — Scan-demand rules for production records: SDU/EWR-sourced records require 1 scan; manual records require GREATEST(1, CEIL(quantity x 0.05)); short_code = COMPANYCODE + 6-digit sequence. VERIFIED (SQL 20260414200926:8-46).

R-13 — One facility cannot serve two active programmes with the same pigment in overlapping date windows. VERIFIED (check_facility_pigment_uniqueness, SQL 20260420114208:96-133).

C. Units and CU mathematics

R-14 — CU and MT are kept in lockstep by trigger (CU = MT x 1000) on purchase orders, reservations and claims; volume_cu is the Tier 0 source of truth. VERIFIED (origin/main SQL 20260807135332:41-88, refined 20260812085344).

R-15 — A nomination header’s volume_cu is the sum of its non-excluded lines’ cu_total, recomputed on every line change. VERIFIED (recompute_po_volume_cu, origin/main SQL 20260807135332:118-134).

R-16 — Category defaults prefill; line-level deviations are flagged (overridden) and audited with CU impact on /admin/overrides, which is read-only reporting (no add-override UI). VERIFIED (AdminOverrides.tsx — zero .insert calls). QC A-13 step 1 has no surface. VERIFIED divergence.

D. Nominations and linking

R-17 — A verification session can be linked to at most ONE purchase order, ever (DB UNIQUE on purchase_order_verifications.session_id). VERIFIED (SQL 20260608132010:53).

R-18 — Linking is guarded by trigger: the session’s facility must belong to the PO partner, or the session must be shared with them; linking is blocked once the PO is fully_claimed or closed; a successful link bumps PO status to ready. VERIFIED (validate_po_verification, SQL 20260608132010). The UI mostly prevents violations by filtering candidates (own facilities OR shared, minus already-linked/claimed), so the explicit error QC M-06 expects may rarely render. VERIFIED (LinkVerificationsToPoModal:134-193).

R-19 — Candidate scans are deliberately unrestricted beyond ownership: any scan at the manufacturer’s own facility is attachable even if addressed to another company (sub-tier import case). A counterparty display is planned mitigation, not a restriction. STATED (BL:wt2:21-22) — consistent with the code’s own-facility-OR-shared filter. VERIFIED consistency.

R-20 — Per-line allocation cannot exceed the line’s remaining quantity (blocks Next); a line’s quantity can never be reduced below its quantity_claimed. VERIFIED (LinkVerificationsToPoModal:256-260,373; usePurchaseOrderLines.ts:105-110).

R-21 — Proposal claim size is capped at min(buyer’s available capacity, programme remaining) with named errors (exceedsProgramme, exceedsBuyerCapacity, programmeExhausted). VERIFIED (LinkVerificationsToPoModal:236-280).

R-22 — The manufacturer, not the brand, sets the claimed tonnage; the PO figure is money/product, not fibre tonnage; one claim never spans two Tier 1 suppliers (two suppliers = two POs). STATED (BL:wt2:38; flow-explained rules 5).

R-23 — Nomination references are unique per issuer + partner while unarchived. VERIFIED (SQL purchase_orders_unique_ref partial unique index).

R-24 — Partnership is a prerequisite: you cannot declare an inbound/outbound shipment against a non-partner, and nominations are shared only to partners; partner invite matching by 3-letter company code is case-insensitive. STATED for the shipment rule (BL:demo:72; flow rule 3) + VERIFIED for invite matching (SettingsPartners.tsx:160-177 ilike).

R-25 — Unmatched or mistyped waybills never block anything: the shipment stays “unconfirmed” and is fixable later (implied chain of custody). STATED (BL:demo:73-74; flow rule 2).

E. Claims and evidence

R-26 — A claim cannot reach status proposed with zero linked verifications (DB trigger). VERIFIED (validate_claim_has_verifications, origin/main SQL 20260814070749).

R-27 — The claim belongs to the retailer: production_claims.company_id = the PO issuer, because the reservation lives on the brand and the metering trigger matches company_id. VERIFIED (usePurchaseOrders.ts:437-441 — explicitly fixing the earlier ownership bug where the proposing manufacturer was written instead).

R-28 — Confirmation side-effects are a package: evidence pack rows (metered: pack volume <= PO remaining), line quantity_claimed commits, PO status recompute (partially/fully_claimed), public tokens seeded for trade/sustainability/procurement, linked sessions lock and unmask. VERIFIED (apply_po_evidence_pack; usePurchaseOrders.ts:640-746,722-732).

R-29 — After confirmation neither party can edit; rejection (with stored reason) deletes the PO’s verification links and reverts the PO to shared so the manufacturer can re-pick; partial claims loop until lines complete. VERIFIED (usePurchaseOrders.ts:773-834) + STATED for the both-parties-locked intent (BL:wt2:30).

R-30 — Admin revoke invalidates the claim/certificate WITHOUT releasing CU or unlinking scans; admin void deletes claim, pack and bindings and frees CU and scans. VERIFIED (usePurchaseOrders.ts:936-1008 — comment “certificate invalidated, CU NOT released”). Risk: the found DB CHECK on status does not include revoked. INFERRED risk (see 06-open-questions Q10).

R-31 — Bulk approval: unchanged proposals auto-selected, variance rows require explicit selection; over-capacity groups get a greedy keep-smallest “Deselect to fit”; confirms run sequentially per PO with no batch rollback. VERIFIED (BulkClaimApprovalModal.tsx:88-183). QC R-07’s “no partial claims on shortfall” does not match the no-rollback behaviour. VERIFIED divergence.

R-32 — Evidence packs never display partial/full status (externally irrelevant by design); the claims list carries those chips. STATED (BL:wt2:37).

R-33 — Retirement is tier-scoped: only the Tier 1’s linked scans retire on confirmation; sub-tier scans never retire and never appear on the claim (they are simply never linked). STATED (BL:wt2:28-29; flow rule 6) — mechanically consistent with R-17/R-28. VERIFIED consistency.

R-34 — Public access to claims/packs exists only through enabled public_verification_tokens (per-pack toggle; revoked claims show a Revoked banner). EXCEPTION: the webhook preview route takes a bare claimId with no token or auth check — a real gap. VERIFIED (public_verification_tokens schema + PublicClaim.tsx:69-96; memory reference_public_claim_webhook).

R-35 — No outbound webhook exists anywhere; “webhook” is a pull-style JSON preview page. VERIFIED (BL:2026-07-16-code-resolved-open-questions.md:45-46; ClaimWebhookPreview at origin/main).

F. Identity, roles, access

R-36 — Personas are derived from data, never stored: producer = programme_producers rows; manufacturer = non-archived incoming POs; retailer = brand/retailer company type + participant rows + not producer; owner = program_owners row (redirects Home to /owner). VERIFIED (usePersonaContext.ts:32-41; Home.tsx:136-151). QC A-05’s “role is derived from registration tables, not from activity” is thereby true for producer/retailer but literally false for manufacturer, which IS activity-derived (receiving a PO). VERIFIED nuance.

R-37 — Team roles gate actions: owner/admin edit company, billing, partners (real BE returns 403 for non-admins on company write and partner accept); auditor is scan-only and force-signed-out of the dashboard; signup approval makes the requester the company’s first admin. VERIFIED (settings isAdmin checks; monet-dashboard-api-v1.md:326,474; Home.tsx:128-133; AdminSignupRequests.tsx:64-121).

R-38 — Facility creation is licence-gated by count: used = non-free active facilities; blocked at zero available (“No licenses available. Buy more licenses first.”). No cost/period exists in the licence model. VERIFIED (CreateFacility.tsx:49-53; AdminLicensing.tsx). QC A-12/M-01 expect a currency amount. VERIFIED divergence.

R-39 — One owner company per programme (delete-then-insert); one producer row per company per programme (UNIQUE); one partnership per unordered pair, never self (UNIQUE + CHECK). VERIFIED (AdminProgrammes.tsx:262-272; SQL uniques).

R-40 — Company confidence score must be 0-100 and is bucketed by trigger: Moderate <= 50, Good <= 80, Excellent above. VERIFIED (SQL 20260513151536:13-35). No BE calculation exists — Lovable-only. VERIFIED gap.

R-41 — Supply-chain visibility: partner companies show name and location; non-partner companies with scans DO appear but masked as “Confidential supplier” with city/country and an Invite CTA. Malcolm’s demo statement that non-partners “don’t appear” contradicts the implementation; his final ruling is outstanding. VERIFIED code behaviour (BL:2026-07-16-code-resolved-open-questions.md:20-24) vs STATED contrary (BL:demo:83).

G. Meta-rules of the platform

R-42 — Mass balance, not item-level trace: the platform proves movement of marked fibre and meters claimed volume; it never asserts bale-to-garment identity. STATED (BL:demo:76; flow rule 1).

R-43 — The MVP is deliberately hardcoded to apparel and FibreTrace’s own chain — a trade-off against the old flexible Canvas, buying near-zero setup for users. STATED (BL:demo; flow rule 4). Its implication for monet’s backend extensibility is unaddressed. UNKNOWN.

R-44 — Production records lock ~30 days after creation (client-derived from created_at, no dedicated column). VERIFIED (memory reference_fibre_production_sundown; FibreCreation lock logic).

R-45 — The Lovable MVP’s auth is demo-grade by design: email lookup with any non-empty password, on-screen 2FA code, emails simulated into localStorage. Every QC pass criterion involving “receives an email” or “access denied” must state auth settings as preconditions. VERIFIED (Login.tsx:102-151; signup.ts:67-70; AdminLayout.tsx:66-72).