KB-4A96

40 — SB-10 Candidate-State Store — Detailed Technical Design (GCOS keystone, no checked-forever, group-grain, fail-closed, design-only, read-only zero mutation, 2026-06-01)

21 min read Revision 1
one-roof-governanceimplementation-indexgcossb-10candidate-state-storegovernance-candidate-statederived-objects-registryno-checked-foreversnapshot-ruleset-keyedgroup-grainno-per-row-explosionobject-materialization-criteriacandidate-verdictfail-closed-g-prodstale-afteridempotencycanonical-address-null-correctionno-hardcodeno-islanddesign-onlybuild-no-go2026-06-01

40 — SB-10 Candidate-State Store — Detailed Technical Design (GCOS keystone)

Package: knowledge/dev/reports/architecture/one-roof-governance-technical-addendum-and-implementation-index-2026-06-01/ Track: GCOS substrate. Blocker SB-10 (the keystone — docs 31/32/33/34 all write here). Status: Detailed technical design ONLY. BUILD NO-GO. No DDL/DML, no table creation. KB document only. Reads / controls: doc 00 → concept canon → Round-4 law → prompt-muc-tieu-mo-for-claude-code.md. Builds on doc 34 (§2 store, §3 group_key, §5 modes, §6 TTL, §8 verdicts), doc 31 (§6 idempotency), doc 33 (input_quality_state), doc 38 (SB-12 snapshot/ruleset), doc 39 (SB-13 cursor). Concept invariant v3 (doc 31 §9). Date: 2026-06-01 · Mutation footprint: KB document only. Zero PG/Directus/Qdrant/Nuxt mutation.


40.0 §0-GOV — governed objects

governed_object class grain purpose
candidate_group_state Class-2 process record one row per group_key per (snapshot, ruleset) the DEFAULT unit of candidate state (anti-explosion)
candidate_object_state Class-2 process record one row per object only when materialization criteria met (§40.6) per-object verdict where the group grain is insufficient
candidate_scan_run Class-2 process record one row per scan run run ledger (links cursor SB-13 + snapshot SB-12)

Issue/event types (register-before-emit, NOT registered; governance domain SB-11): candidate_stale, candidate_unknown, candidate_scan_lag, group_invalidation_storm (doc 34 §10).


40.1 Problem statement — and the anti-pattern it must NOT reproduce

The candidate scan (doc 34) decides, for each governance-relevant unit, whether it is relevant (→ feed T6), not_relevant, class_0, deferred_birth, retired, needs_input, or unknown/stale. SB-10 is the durable store of those verdicts. Two anti-patterns are forbidden (GPT keystone instruction, doc 34 §1):

  1. "Checked forever" boolean — storing is_governed = true as a permanent truth. Forbidden. A verdict is always qualified by the triple (source_snapshot_ref, ruleset_version, scan_time) (SB-12). A clean verdict means "under snapshot S, ruleset R, at time T, this was not_relevant" — automatically invalid when S, R, or the TTL changes.
  2. Per-row explosion — one state row per child record. Forbidden at 1.04M+ (and 10⁶/10⁸ inherited children). State is kept at governance grain (group-level by default; object-level only by explicit criteria, §40.6); inherited children collapse to their anchor group (M-DEF-7, Δ = 0).

40.2 Live PG validation (read-only, re-verified 2026-06-01)

Object Live finding Implication for SB-10
governance_candidate_state ABSENT (0 cols). Greenfield — must be created (gated).
derived_objects_registry 7 rows. Cols: code, object_type, definition_source, depends_on_collections ARRAY NN, depends_on_edges ARRAY, refresh_strategy, refresh_mode, freshness_sla_seconds, stale_after tstz, last_computed_at, recompute_status default 'ok', stale_reason, status default 'active', _dot_origin. Live values: refresh_strategy/mode ∈ {realtime_trigger(2), on_demand(3), null(2)}; recompute_status all 'ok'. The live dirty/stale precedent. Key insight: it has no is_fresh/checked boolean — freshness is computed from stale_after + recompute_status + last_computed_at, with depends_on_collections[]/depends_on_edges[] = "what dirties me." SB-10 models on exactly this (§40.4). There is no literal periodic_full enum — periodic = the stale_after TTL net (doc 34 §5).
birth_registry.canonical_address NULL in ALL 1,037,724 rows. entity_code/collection_name NOT NULL, 0 nulls. Correction to doc 31 §6 / doc 34 §2: candidate_key cannot be canonical_address. Effective key = **`collection_name
system_issues 190,288 rows. coalesce_key text, occurrence_count int default 1, reopen_count int NN 0, business_logic_hash, violation_hash, run_id, parent_issue_id bigint, evidence_snapshot json, status, first_seen_at/last_seen_at. issue_type is free-text (nullable, no CHECK). The finding/fingerprint substrate the candidate row links to (issue_ref, evidence_fingerprint); reuse business_logic_hash/run_id/coalesce_key; no schema change to system_issues.
entity_audit_queue 1 row. Per-record: (collection_name, record_id), issue_type NN, audit_status NN, audit_owner, audit_deadline, resolved_at. A live per-record audit queue — the contrast SB-10 must avoid as its default grain (per-record = explosion). SB-10 is group-grain by default; entity_audit_queue is a precedent only for the object-level materialization case (§40.6), not the default.
collection_registry.coverage_status 168 rows; BIRTH_REQUIRED(74 IN_SCOPE), BIRTH_DEFERRED_NEEDS_REVIEW(31 USER_EXCLUDED / 17 FUTURE_SCOPE / 7 ORPHAN_REGISTRY / 3 IN_SCOPE), BIRTH_EXEMPT_*(20+12+4 IN_SCOPE). Seeds the initial group verdict bias: BIRTH_EXEMPT_*retired/ignored; USER_EXCLUDED/FUTURE_SCOPEclass_0/deferred; BIRTH_REQUIRED IN_SCOPE → candidate (doc 31 §3). SB-10 reads it; does not widen it.

40.3 Reuse / Extend / New decision

Need Decision Rationale
Candidate-state store NEW governance_candidate_state (group-grain) + optional governance_candidate_object (materialized detail), modeled on derived_objects_registry No existing table stores per-group governance verdicts keyed by snapshot+ruleset. The dirty/stale pattern is fully reused from derived_objects_registry; only the table is new (greenfield, additive).
Dirty/stale semantics REUSE the derived_objects_registry idiom (depends_on_* = invalidation graph; stale_after; recompute_status; stale_reason) Proven live dirty/stale model; no new freshness engine.
Finding linkage / fingerprint REUSE system_issues (issue_ref→id, business_logic_hash, run_id, coalesce_key) One issue store; SB-10 links, never duplicates.
Snapshot / ruleset keys REUSE SB-12 (source_snapshot_refevolution_snapshots.id, ruleset_versiongovernance_ruleset) The triple lives across SB-10/SB-12.
Coverage decision seed REUSE collection_registry.coverage_status Live ledger; read-only seed bias.

Net: SB-10 = 1 new table (default) + 1 optional materialization table, both reuse-shaped. No second issue store, no second freshness engine, no widening of collection_registry/system_issues.


40.4 Schema — governance_candidate_state (group-grain default)

governance_candidate_state
  -- IDENTITY ----------------------------------------------------------------
  group_key            text  NN     -- hash(object_class, source_registry, axis_family, scope,
                                     --       lifecycle_status, owner_scope)  (doc 34 §3)
  -- VERDICT QUALIFIED BY THE TRIPLE (no checked-forever) ---------------------
  source_snapshot_ref  int   NN     -- → evolution_snapshots.id  (SB-12)
  ruleset_version      text  NN     -- → governance_ruleset.ruleset_version  (SB-12)
  scan_time            timestamptz NN
  candidate_verdict    text  NN     -- relevant | not_relevant | class_0 | deferred_birth
                                     --   | retired | needs_input | unknown   (doc 34 §8)
  -- INPUT GATE (doc 33) ------------------------------------------------------
  input_quality_state  text         -- accepted_for_candidate_scan | incomplete_input | ...(10 states)
  input_quality_ref    bigint       -- → system_issues.id when an input-quality issue was raised
  -- DIRTY / STALE (modeled on derived_objects_registry) ----------------------
  dirty                boolean NN default false   -- set by invalidation (doc 34 §4); NOT a "clean-forever" flag
  dirty_reason         text                       -- handoff kind | ruleset_bump | snapshot_drift | ttl
  dirtied_at           timestamptz
  depends_on           jsonb NN default '{}'      -- { collections:[...], rule_scopes:[...], axis_families:[...] }
                                                  --   = "what dirties me" (reuse derived_objects_registry.depends_on_*)
  stale_after          timestamptz                -- scan_time + ttl(risk_class)  (doc 34 §6)
  recompute_status     text default 'ok'          -- ok | dirty | scanning | failed   (reuse idiom)
  -- RISK + COVERAGE ----------------------------------------------------------
  risk_class           text  NN     -- from coverage profile M-DEF-2 (write/high | read/descriptive | ...)
  coverage_required    boolean NN   -- does this group require T6 coverage? (false → not_relevant/class_0)
  coverage_scan_ref    text         -- run_id of the T6 coverage scan that consumed this group (doc 34 §9)
  -- LINKAGE / AUDIT ----------------------------------------------------------
  issue_ref            bigint       -- → system_issues.id (open finding for this group, if any)
  last_run_id          text         -- → candidate_scan_run / system_issues.run_id
  evidence_fingerprint text         -- state hash of the group's inputs (reuse business_logic_hash idea)
  lifecycle_status     text  NN default 'active'  -- active | superseded | retired
  audit_ref            text         -- registry_changelog linkage
  owner_scope          text         -- SB-2 owner scope (when live); null pre-SB-2 (degrades, never guesses)
  created_at           timestamptz NN default now()
  updated_at           timestamptz NN default now()
  -- intended UNIQUE: (group_key, ruleset_version)   [one live verdict per group per ruleset;
  --   prior (group_key, ruleset_version) rows kept as version history via lifecycle_status]

The no-checked-forever guarantee is structural: there is no is_governed/checked boolean. The closest thing, dirty, means "needs re-eval" (the opposite of "done forever"). A clean state is recompute_status='ok' AND dirty=false AND now() < stale_after AND ruleset_version = current — a conjunction that decays as soon as the snapshot drifts, the ruleset bumps, or the TTL passes. There is no way to express "clean regardless of S/R/T."


40.5 Idempotency key (corrected for the live NULL canonical_address)

candidate_key = COALESCE(canonical_address, collection_name || ':' || entity_code)

Because canonical_address is NULL for all 1,037,724 birth rows (live), the effective object idempotency key is collection_name || ':' || entity_code (both NOT NULL). This candidate_key is used only on materialized object rows (§40.6) and for cross-referencing findings; the default group row is keyed by (group_key, ruleset_version). Upserts on (candidate_key, ruleset_version) (object) and (group_key, ruleset_version) (group) make re-seeding/replay no-ops (SB-13 §39.4).


40.6 Group-level vs object-level — and exactly when to materialize object detail

Default = group-level. One governance_candidate_state row per group_key per (snapshot, ruleset). The 1.04M born objects collapse to O(#groups) rows = O(object_class × source_registry × axis_family × scope × lifecycle × owner_scope), bounded by the live cardinalities (78 registries, 169 classes, 39 species, 6 SB-2 scopes) — thousands, not millions. Inherited children are represented by their anchor group (M-DEF-7) → Δrows = 0 when 10⁶ children are added under an anchored container.

Materialize an object-level row (governance_candidate_object) ONLY when one of these is true (else the group row suffices):

  1. The object is independently authoritative — not an inherited child (M-DEF-10: a count>1 dimension is an axis, an independent object is its own candidate). Independent objects get their own row.
  2. There is an open finding on that specific object (issue_ref set) — the object needs individual tracking until resolved.
  3. The object is under a governed exception (M-DEF-6) — TTL/fingerprint tracked per object.
  4. The object is high-risk and write-path and the G-PROD gate must consult per-object status (not just group) before allowing a production action.

This bounds object rows to "objects that actually need individual governance attention," not "every row that exists" — the same discipline entity_audit_queue uses for per-record audit, but applied selectively rather than as the default. Materialized object rows carry the same columns + candidate_key, and link back to their group_key.

Detail is dematerialized when its reason lapses (finding auto-closed, exception expired, risk reclassified) → the object row is retired (lifecycle_status='retired') and the group row again represents it.


40.7 Invalidation rules (what flips a verdict)

Consumes SB-12 §38.6 + handoff (doc 32 §3). Each trigger dirties the smallest group it provably affects (reuse derived_objects_registry.depends_on_* graph, stored in depends_on jsonb):

Trigger Sets Scope dirtied
Handoff born/registered/retired/collection/count/source (doc 32 #1–6) dirty=true, dirty_reason='handoff:<kind>' (object_class, source_registry, lifecycle) group
Axis introduced / axis policy (doc 32 #7) dirty=true, dirty_reason='axis' axis_family groups
Policy changed (law/normative/measurement → ruleset) (doc 32 #8) + ruleset_version differs scope groups the rule governs
Owner/approval/exception changed (doc 32 #9, SB-2/Đ32) dirty=true, dirty_reason='authority' owner_scope + scope groups
Ruleset bump (doc 31 §5; SB-12) rows whose ruleset_version ≠ active groups in changed rule's scope (auto-close re-keyed by (coalesce_key, ruleset_version))
Source-snapshot drift (SB-12 delta_previous) dirty=true, dirty_reason='snapshot_drift' groups with changed fingerprint
TTL expiry (stale_after) candidate_verdict → unknown/stale the single expired row
Input correction / late data (doc 33 §5) dirty=true the corrected candidate's group

Storm guard: if one tick dirties > a configured fraction of all groups → group_invalidation_storm (high) + scan throttle (doc 34 §4, Branch F #5).


40.8 Verdict → next, deletion/retire, and fail-closed production gate

Verdict → next (doc 34 §8): only relevant consumes expensive T6 owner/profile work. not_relevant/class_0 → record, no issue, re-eval only if dirtied/expired. deferred_birth → yield to Đ19, 0 governance issue (M-DEF-4). retired → counted in invariant retired/ignored. needs_input → route input-quality issue, hold (doc 33). unknown/stale → fail-closed for high-risk.

Deletion / retire: retired/superseded/merged objects → candidate_verdict='retired', lifecycle_status='retired', tombstone persists (re-birth detectable); a row whose source disappeared between snapshots → verdict='retired', dirty_reason='source_row_gone'. Never hard-delete a candidate row (reproducibility + invariant accounting). A group whose members all retire → group row lifecycle_status='retired', retained for the invariant ledger.

Production fail-closed gate (concept §11 G-PROD; doc 34 §6; doc 35 §3.2 patch #10):

  • A high-risk object/group whose state is unknown or stale (or references a dangling snapshot, or recompute_status='failed') ⇒ G-PROD BLOCKS the production/execution action. Unknown ≠ safe.
  • A low-risk/descriptive object ⇒ scheduled re-scan, not a block.
  • A group with coverage_required=true but no closing coverage scan ⇒ blocks high-risk; raises candidate_unknown.

Coverage invariant v3 (doc 31 §9), per scope S, from candidate-state rows under the current (snapshot, ruleset): total(S) = covered + orphans + approved_exceptions + retired/ignored + stale + deferred_birth + class_0. Non-closing ⇒ governance_schema_drift (T7 #16). This is the reconciliation that proves "no object silently uncovered."


40.9 Avoiding the four forbidden outcomes (explicit)

Forbidden How SB-10 avoids it
"Checked forever" boolean No is_governed column; verdict structurally qualified by (snapshot, ruleset, scan_time) + decaying clean-conjunction (§40.4).
Per-row explosion for inherited children Group-grain default; inherited children → anchor group (Δ=0, M-DEF-7); object rows only by §40.6 criteria.
Hardcoded object/axis lists group_key dims discovered from meta_catalog/birth_registry/pivot_definitions/law_jurisdiction/SB-2 scopes; risk_class/coverage_required from coverage profile M-DEF-2; nothing literal in code.
UI full-table scan UI/ops read summary views only (counts, invariant ledger per scope) — never the raw candidate store (Đ28; doc 25 §6; Branch F #14).

40.10 Acceptance tests (build-time; cannot run now)

  1. No checked-forever: there is no boolean meaning "governed regardless of S/R/T"; every clean verdict resolves to a triple and decays on snapshot drift / ruleset bump / TTL.
  2. No explosion: seed all 1,037,724 born objects ⇒ candidate-state rows = O(#groups) (thousands), not millions; adding 10⁶ inherited children under one anchored container ⇒ Δrows = 0.
  3. Object materialization is selective: object rows exist only for independently-authoritative / open-finding / under-exception / high-risk-write objects (§40.6); count ≪ total objects.
  4. Targeted invalidation: a handoff/ruleset/snapshot change dirties only the provably-affected groups (Δother = 0); idempotent re-seed is a no-op.
  5. Fail-closed: high-risk unknown/stale ⇒ G-PROD blocks; low-risk ⇒ scheduled re-scan; verified against the readiness gate.
  6. Invariant closes: total = covered + orphans + exceptions + retired + stale + deferred_birth + class_0 per scope; non-closing raises governance_schema_drift.
  7. Idempotency under live data: candidate_key = collection:entity_code (canonical_address NULL) yields unique, stable keys for all 78 registries; replay double-commits nothing.
  8. Retire is reversible-tombstone: retired rows persist; re-birth re-activates; no hard-delete.

40.11 Dependencies, gates, verdict

  • Designable now: YES (done). Build now: NO.
  • Build gates: governance_candidate_state (+ object table) DDL = gated (operator, reversible-by-default). Verdicts keyed on SB-12 (snapshot/ruleset) and seeded/dirtied by SB-13 (cursors) and SB-11 (handoff signals) — SB-10 is the convergence point, so its build follows SB-12+SB-13 and precedes intake/seed. Owner-relevant verdicts (owner_scope) degrade gracefully until SB-2 (governance_object_ownership + governance_responsibility_scope) is live — SB-10 stores owner_scope=null and never guesses (fail-closed). C-7 owns input-trust (input_quality_state) policy.
  • No COMMIT (os_proposal_approvals=0).

SB-10 design verdict: COMPLETE — GO for build-prep, BUILD NO-GO. Decision = NEW governance_candidate_state (group-grain, modeled on derived_objects_registry) + optional governance_candidate_object for selectively-materialized detail. The keystone correctly forbids the "checked-forever" boolean (verdict = decaying triple), forbids per-row explosion (group-grain + Δ=0 inheritance + selective object materialization), forbids hardcode (registry-sourced dims) and UI full-scan (summary views), and fails closed for high-risk unknown/stale. The one material live correction: candidate_key = collection_name:entity_code, not canonical_address (universally NULL).

(Cross-refs: doc 31 §3/§6/§9, doc 33 §4, doc 34 §2/§3/§6/§8/§9, doc 35 §3.2 patch #1/#10, doc 38 SB-12, doc 39 SB-13, doc 41 SB-11, doc 42 integration.)

Back to Knowledge Hub knowledge/dev/reports/architecture/one-roof-governance-technical-addendum-and-implementation-index-2026-06-01/40-sb10-candidate-state-store-detailed-design.md