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)
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):
- "Checked forever" boolean — storing
is_governed = trueas 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 wasnot_relevant" — automatically invalid when S, R, or the TTL changes. - 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_SCOPE → class_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_ref→evolution_snapshots.id, ruleset_version→governance_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):
- 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.
- There is an open finding on that specific object (
issue_refset) — the object needs individual tracking until resolved. - The object is under a governed exception (M-DEF-6) — TTL/fingerprint tracked per object.
- 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
unknownorstale(or references a dangling snapshot, orrecompute_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=truebut no closing coverage scan ⇒ blocks high-risk; raisescandidate_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)
- 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.
- 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.
- Object materialization is selective: object rows exist only for independently-authoritative / open-finding / under-exception / high-risk-write objects (§40.6); count ≪ total objects.
- Targeted invalidation: a handoff/ruleset/snapshot change dirties only the provably-affected groups (Δother = 0); idempotent re-seed is a no-op.
- Fail-closed: high-risk
unknown/stale⇒ G-PROD blocks; low-risk ⇒ scheduled re-scan; verified against the readiness gate. - Invariant closes:
total = covered + orphans + exceptions + retired + stale + deferred_birth + class_0per scope; non-closing raisesgovernance_schema_drift. - Idempotency under live data:
candidate_key = collection:entity_code(canonical_address NULL) yields unique, stable keys for all 78 registries; replay double-commits nothing. - 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 storesowner_scope=nulland 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.)