diff --git a/INTENT.md b/INTENT.md index b8bf976..407e44d 100644 --- a/INTENT.md +++ b/INTENT.md @@ -19,6 +19,18 @@ declared_at: "2026-08-29" conformance_record: docs/conformance/security-layer-conformance.md pep_stance: null # not PEP-shaped: flex-auth renders decisions, it causes no protected side effect tooling_contacts: [] # §5 binds Staff; flex-auth holds no Tooling client +# +# §11 emission guarantee. GH-DEC-2026-018 ruled that a custodian is never the +# source of what it holds, so flex-auth is the §4 source of evidence for the +# decision record and owes a guarantee PER EVENT CLASS. audit-core declined the +# role on its own authority (AUDIT-IN-0005). §4 gains an evidence-source +# marking under A10; this key is the repository's own side of that marking, so +# no run has to infer it. The class inventory and its classification are +# flex-auth's to publish — §11 forbids a run inferring class from event name, +# payload or observed rate, and GH-DEC-2026-018 §4 declined to classify on +# flex-auth's behalf for exactly that reason. +source_of_evidence: true +emission_guarantee: cadence.yaml --- # Flex-Auth Intent diff --git a/SCOPE.md b/SCOPE.md index 2cf2ce3..dec64d5 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -197,20 +197,22 @@ four more in the v0.8 round (`FLEX-DEC-2026-011`), of which F1 — that a decisi must be *attributable* to flex-auth and that a digest comparison does not discharge it — was the finding of the round. -Conformance state is **conforming with three declared gaps**, each with an owner -and a route rather than a sentence: +Conformance state is **not conforming on §11, held as three declared gaps**, +each with an owner and a route rather than a sentence. G2 is the non-conformance +(`GH-DEC-2026-018`): | # | Gap | Owner | Route | | --- | --- | --- | --- | | G1 | Registry-snapshot digest absent from decision provenance (§9.7.2 conformance prerequisite) | `flex-auth` | `FLEX-WP-0019` | -| G2 | No emission guarantee declared, where §11 requires one of every §4 source of evidence | `gate-house` to rule, then `flex-auth` | `FLEX-WP-0030` B3 | +| G2 | Emission guarantee declared per event class (`cadence.yaml`) but not delivered — no outbox, heartbeat, or audit-core sender registration. Review 2026-10-19 | `flex-auth` | `FLEX-WP-0031` | | G3 | Published stance-register review stale — written at two register rows, §13.1 now carries five | `flex-auth` | `FLEX-WP-0029` | -G2 is new and is recorded as a gap rather than as conformance on purpose: whether -flex-auth is a §4 *source of evidence* or only the producer of an artifact -`audit-core` sources has been asserted by nobody but flex-auth, and §11 says a -source declaring no emission guarantee is not conforming. The conservative entry -is the honest one until the boundary is ruled. +G2 was held open as a question — is flex-auth a §4 *source of evidence*, or only +the producer of an artifact `audit-core` sources? — and `gate-house` ruled it +against flex-auth on 2026-09-21: a custodian is never the source of what it +holds. `audit-core` reached the same answer on its own authority. The +classification is flex-auth's and is published; the delivery is the gap, and it +fails closed (a missed detection, never a manufactured permission). **Boundaries review (2026-09-21).** `FLEX-WP-0030` reviews flex-auth's boundary against every security-relevant counterpart and raises five items: inconsistent @@ -343,5 +345,5 @@ description: Published decision-record schema, the submitted-request digest as t type: orientation title: Layer declaration status: current -description: Machine-readable Engine/PDP boundary declaration in INTENT.md frontmatter, carrying no standard version by design, with version-stamped conformance state and three declared gaps in docs/conformance/security-layer-conformance.md, per Security Layer Model section 11. +description: Machine-readable Engine/PDP boundary declaration in INTENT.md frontmatter, carrying no standard version by design, with version-stamped conformance state and three declared gaps (G2 a ruled §11 non-conformance) and a per-event-class emission declaration in cadence.yaml, per docs/conformance/security-layer-conformance.md, per Security Layer Model section 11. ``` diff --git a/cadence.yaml b/cadence.yaml new file mode 100644 index 0000000..7278b61 --- /dev/null +++ b/cadence.yaml @@ -0,0 +1,146 @@ +# flex-auth (access-engine) — §11 emission guarantee, per event class. +# +# Authority: security-layer-model §9.6 and §11; GH-DEC-2026-018 §2 and §3; +# net-kingdom/canon/standards/emission-cadence-security-profile_v0.1.md. +# Reference instance: approval-engine/cadence.yaml. Detection form: +# gate-house/docs/contracts/approval-emission-detection.md. +# +# WHY THIS FILE EXISTS AND WHY IT IS PER CLASS. +# +# flex-auth asked whether it is a §4 source of evidence for the decision record +# or only the producer of an artifact audit-core sources, declined to take the +# reading that favoured it, and held the question open as declared gap G2. +# GH-DEC-2026-018 §1 ruled that a custodian is never the source of what it +# holds: §9.6 rests on an archive being unable to prove a record was never +# sent, and that sentence has content only because archive and emitter are +# different parties. audit-core reached the same conclusion on its own +# authority (AUDIT-IN-0005) and declined the role. So flex-auth is the source. +# +# GH-DEC-2026-018 §4 ruled the declaration is PER EVENT CLASS and deliberately +# did NOT classify these classes, because §11 forbids a conformance run to +# infer class from event name, payload or observed rate and that prohibition +# binds a ruling as hard as it binds a runner. The classification below is +# therefore flex-auth's, published as §11 requires the source to publish it, +# and it is supplied to a checker rather than derived by one. +# +# The reason it is per class and not per repository: one guarantee averaged +# over a stream carrying both a high-volume allow and a rare deny is an +# average, not a declaration, and rate monitoring over that average cannot see +# the deny go missing. A denial is one of the three paradigm load-bearing +# events §9.6 names, and it is the event an adversary most wants absent. + +schema_version: "0.1" +source: flex-auth # §4 row `access-engine`; FLEX-DEC-2026-013 +kind: load-bearing +form: heartbeat-or-reconciliation +rate_monitoring: forbidden # class-level exception stated on `allow` only + +# STATE. The classification below is published and in force as a declaration. +# The emission pipeline is not built: no sender named flex-auth or +# access-engine is registered with audit-core, no token lane, no scope row, and +# the decision record reaches consumers in the /v1/check response rather than +# into any custody. This file is therefore the declaration §11 requires and not +# a claim that the guarantee is delivered today. The delivery gap is G2 in +# docs/conformance/security-layer-conformance.md, with owner, blocker and +# review date, and it fails CLOSED on its distinguishing case: a stream with no +# declared cadence produces no silence finding, so the failure mode is a missed +# detection and never a manufactured permission. +state: declared-not-yet-emitting +gap: G2 # docs/conformance/security-layer-conformance.md + +heartbeat: + class: flex-auth.decision.heartbeat + interval: 24h + assertion: nothing-to-report + missing: finding + +reconciliation: + # Declared for EVERY class, including the volume one. Rate monitoring can see + # a stream stop; it cannot see a targeted subset removed, and a suppression + # aimed at one subject or one tenant is exactly the shape that leaves the + # aggregate rate inside its window. + compare: + local: committed outbox counts per class + remote: "audit-core event counts where source=flex-auth" + divergence: finding + undrained_local: lag-not-divergence + +lag_bound: + outbox_depth: 100 + outbox_age: 1h + exceed: finding + +# EVENT CLASS INVENTORY. +# +# The classes are the decision-record effect vocabulary of +# `flex-auth.decision-record.v1` (docs/decision-record-contract.md, +# pkg/api.DecisionEffect). Every one of them is load-bearing: a decision record +# is the artifact a consumer's authority rests on, and §17 moved its schema +# here on the ground that it is the one thing in the estate only this +# repository produces. +classes: + deny: + action: flex-auth.decision.deny + evidence_class: load-bearing + rarity: rare + rate_monitoring: forbidden + detection: [heartbeat, reconciliation] + note: >- + §9.6's paradigm case, named there by the standard. Rare by nature, which + is where rate monitoring is the wrong instrument and the stakes are + highest. + redact: + action: flex-auth.decision.redact + evidence_class: load-bearing + rarity: rare + rate_monitoring: forbidden + detection: [heartbeat, reconciliation] + note: >- + A partial denial. It restricts authority, so it carries the same + suppression incentive as a deny and is classified with it. + not_applicable: + action: flex-auth.decision.not_applicable + evidence_class: load-bearing + rarity: rare + rate_monitoring: forbidden + detection: [heartbeat, reconciliation] + note: >- + No policy matched. A consumer fails closed on it, so its absence has the + same effect as a suppressed deny. + audit_only: + action: flex-auth.decision.audit_only + evidence_class: load-bearing + rarity: rare + rate_monitoring: forbidden + detection: [heartbeat, reconciliation] + note: >- + The effect whose entire purpose is the record. A record of it that does + not arrive is the decision not having happened. + allow: + action: flex-auth.decision.allow + evidence_class: load-bearing + rarity: volume + rate_monitoring: permitted # §3 of the profile: explicitly classified rate-suitable + expected_rate: + window: 1h + minimum: 1 + below_minimum: finding + detection: [expected-rate, reconciliation] + note: >- + Classified volume, and stated as a classification rather than left to be + inferred from observed traffic. Reconciliation is declared anyway, + because rate monitoring over an allow stream cannot see a targeted subset + removed. An allow carries a TTL lifetime, so a suppressed allow expires + into a denial of service rather than into a standing grant. + heartbeat: + action: flex-auth.decision.heartbeat + evidence_class: operational + rarity: scheduled + note: The positive assertion above; not a decision. + +bound: >- + Heartbeat and reconciliation detect loss, outage, drain failure and accident. + Neither detects a compromised source suppressing an event and its own count + together — §9.6's stated residual, and §16 puts the independent observer + outside audit-core's scope. Conformance to this declaration is not a claim of + stream completeness. diff --git a/decisions/decisions.md b/decisions/decisions.md index 4f627dc..49f32ca 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -1833,3 +1833,61 @@ to finish before a repository-coordinate rename, and they must not be cancelled or rewritten by it. Live Forge apply (`T06`) still needs the exact human confirmation string and a fresh zero-blocker preflight of the commit being renamed. + +--- + +## FLEX-DEC-2026-015 — `resource.system` follows the runtime, never the repository + +**Date:** 2026-09-21 +**Status:** accepted +**Workplan:** `FLEX-WP-0030-T08`; bears on `FLEX-WP-0020` +**Asked by:** `ops-warden`, `WARDEN-IN-0003` + +**Question.** `ops-warden` sends a lane's `owner_repo` verbatim as +`resource.system` (and as `context.owner_repo`) on every `/v1/check`. Exactly one +lane, `flex-auth-policy-check`, names `flex-auth` as `owner_repo`. Should +`resource.system` follow the repository coordinate at rename, or the runtime? + +**Ruling.** It follows the **runtime**, and flips only when the runtime name +flips — which `FLEX-DEC-2026-013` says it does not, at this rename. + +`resource.system` is **policy vocabulary**, not a coordinate. It is the key the +PDP matches on in two places, and both fail closed on an unknown value: + +- the caller-binding table (`ADR-0004`, `internal/callerauth`) binds each + `resource.system` to exactly one authenticated workload principal; an unknown + system is rejected; +- policy packages select on it, so a value no package names produces + `not_applicable`, which a consumer treats as deny. + +`FLEX-DEC-2026-013`'s retain table already keeps "policy/API vocabulary" as +`flex-auth`. So flipping `owner_repo` at the repository rename would rename a +policy resource under cover of a coordinate change. It would not fail at the +edit; it would fail at the check, in production, as a denial nobody decided. +`ops-warden`'s instinct — hold `owner_repo`, add `access-engine` to +`need_keywords` so routing resolves under both names — is the right one and is +confirmed. + +**When it does change, the order is fixed: PDP first, consumers second.** A +later runtime rebrand (the post-soak workplan `FLEX-DEC-2026-013` anticipates) +MUST first ship a binding and package that accept both values, then let each +consumer flip on its own schedule, then retire the old value. A consumer that +flips before the PDP accepts the new value is a fail-closed outage; a PDP that +drops the old value before consumers flip is the same outage from the other +side. Neither is a repository-rename step. + +**Observation, not a ruling on `ops-warden`'s catalog.** `owner_repo` is doing +two jobs — repository coordinate and policy resource — and this question arose +only because they share a field. Separating them (for example a +`policy_system` field defaulting to today's value) would make the next rename a +coordinate-only change by construction. That is `ops-warden`'s schema and its +call; flex-auth only states that the PDP matches on the resource, not on the +repository. + +**Also answers `WARDEN-WP-0039-T03`'s framing.** The credential proxy's admitted +policy binding reads the same field from the delegated-read side. This ruling +applies there identically: the repository rename answers nothing about it. + +**Reversal condition.** Reverses only by a runtime-rename decision that +supersedes `FLEX-DEC-2026-013`'s retain row for policy vocabulary — and that +decision inherits the PDP-first order above. diff --git a/docs/conformance/boundaries-review.md b/docs/conformance/boundaries-review.md index ef6fce8..803ba92 100644 --- a/docs/conformance/boundaries-review.md +++ b/docs/conformance/boundaries-review.md @@ -39,7 +39,7 @@ reason the four cases below went unnoticed. | `zone-engine` | zone **membership** compiles into the registry snapshot flex-auth consumes; per-zone **stance** is the consumer's | **held by flex-auth since 2026-08-19; not contested, not confirmed** | | `user-engine` | PIP; supplies subject facts | **agreed** | | `tenant-engine` | PIP and consumer. What `CheckRequest.tenant` denotes on the write API — caller, target, or guardrail scope — is **unanswered since 2026-09-15** | **unclear, and live** — `FLEX-WP-0022`, re-asked 2026-09-20 | -| `audit-core` | records evidence; flex-auth produces the decision record | **unclear** — which of the two is the §4 *source of evidence* decides whether flex-auth owes an emission guarantee (B3 / G2) | +| `audit-core` | holds custody of evidence; flex-auth **emits** the decision record | **ruled 2026-09-21, against flex-auth** — flex-auth is the §4 source; custody is never source (`GH-DEC-2026-018`); `audit-core` confirmed independently and declined the role (`AUDIT-IN-0005`). G2 is now a dated gap | | `maturity-engine` | PIP; supplies maturity claims. §9.5 forbids ranking blocked-clean below conforming | **agreed** | | `kings-guard` | Staff; raised the authentication/assurance evidence gap that flex-auth **declined** (§13, `FLEX-DEC-2026-002`) | **agreed, by mutual declining** | | `ops-mason` | catalogued PEP-shaped in §4 | **undeclared twice over** — no stance map (§13.1 marks it), no `layer:` key (B2) | @@ -48,13 +48,13 @@ reason the four cases below went unnoticed. Full statements in `workplans/FLEX-WP-0030-boundary-declaration-cleanup.md` T04. -| # | Finding | Owner | -| --- | --- | --- | -| B1 | **Corrected 2026-09-21.** Nine of nine repositories carrying both §11 forms declare a different `layer:` value in each. §11 does not say which form governs | `gate-house` | -| B2 | `gate-house`, `key-cape`, `ops-mason`, `net-kingdom` carry no machine-readable layer declaration | each named repository | -| B3 | flex-auth declares no emission guarantee; whether it owes one turns on an unruled boundary with `audit-core` | `gate-house`, `audit-core` | -| B4 | a layer declaration should not pin a standard version; if §11 agrees it should say so generally | `gate-house` | -| B5 | canon names `access-engine`; the repository still answers to `flex-auth` and the rename has not landed | `gate-house` to note | +| # | Finding | Owner | Outcome | +| --- | --- | --- | --- | +| B1 | **Corrected 2026-09-21.** Nine of nine repositories carrying both §11 forms declare a different `layer:` value in each. §11 does not say which form governs | `gate-house` | **Ruled** `GH-DEC-2026-017` §1–§2: `INTENT.md` governs, sidecar is derived and must agree, the disagreement is still reported; comparison folds ASCII case. flex-auth's validator changed | +| B2 | `gate-house`, `key-cape`, `ops-mason`, `net-kingdom` carry no machine-readable layer declaration | each named repository | `gate-house` declared `Staff` in the ruling commit; three remain | +| B3 | flex-auth declares no emission guarantee; whether it owes one turns on an unruled boundary with `audit-core` | `gate-house`, `audit-core` | **Ruled against flex-auth** `GH-DEC-2026-018`; per-class inventory published in `cadence.yaml`; delivery is dated gap G2 | +| B4 | a layer declaration should not pin a standard version; if §11 agrees it should say so generally | `gate-house` | **Ruled as asked** `GH-DEC-2026-017` §5, A12 | +| B5 | canon names `access-engine`; the repository still answers to `flex-auth` and the rename has not landed | `gate-house` to note | A13 notes it; ping when `FLEX-WP-0020` lands | ## What flex-auth is not claiming @@ -63,7 +63,7 @@ Full statements in `workplans/FLEX-WP-0030-boundary-declaration-cleanup.md` T04. written the key; `gate-house` is the clearest case. - **Not a request that any peer change casing.** B1 may equally be resolved by ruling the vocabulary case-insensitive, which would make flex-auth's validator - the thing that changes. + the thing that changes. *(It was, and it did — see the second correction.)* - **Not a grade.** §9.3's two-owner split is flex-auth's own finding and it cuts here: the PDP does not get to score the repositories whose facts it consumes. @@ -110,3 +110,49 @@ tests and a receipt, and why this correction is recorded here rather than edited away. A published review corrected silently is `FLEX-DEC-2026-008`'s defect, and that rule does not have an exception for the reviewer. + +## Correction — the validator, 2026-09-21 + +**This review treated flex-auth's validator as the §3 vocabulary. It was not, +and it was wrong.** + +`internal/layer` admitted `{Staff, Engine, Tooling}`. §3 enumerates **four** +layers — Taxonomy, Tooling, Engines, Staff — and §3.1 defines Taxonomy, which +§4 catalogues twice (`info-tech-canon`, `net-kingdom`) and which the standard +itself is an instance of. The three-token set was built from §4's role-typed +catalog rows rather than from §3's layer table, so it took §4's spelling +(`Engine`) and lost §3's fourth row. Where this review and its survey said +"outside the §3 vocabulary as written", they meant "outside flex-auth's +validator", and the difference is the finding. + +**`railiance-master`'s `Taxonomy` declaration was conforming all along; the +validator was the divergent artifact.** `railiance-master` said so with the +citations (§3.1, §4, §7, §17) against a record that had named it +non-conformant. `GH-DEC-2026-017` §3 ruled the same. + +How it happened is §11's derived-artifact rule inverted: an artifact derived +from canon came to stand in for canon, because canon left the token set to be +inferred from two tables that disagree and the validator was the only +executable statement of one. flex-auth wrote that artifact. + +**Fixed** in `internal/layer`, per `GH-DEC-2026-017` §2–§4 and A9, A11: + +- four tokens, closed: `Taxonomy`, `Tooling`, `Engine`, `Staff`; +- ASCII case folded before comparing; a lowercase declaration is conforming; +- §4's column spelling is canonical, so `engine` reports as `Engine` and + `Engines` (§3's plural heading) is not the token; +- `INTENT.md` governs; a disagreement between the two forms is still reported, + and a spelling-only disagreement is distinguished from a disagreement about a + layer (there are nine of the first and none of the second); +- **a run states its scope.** §11 binds §4. The survey now leads with its scope + (`estate-wide` or `§4 catalog`, `--catalog-only`), and reports a repository + outside §4 that declares as *declared voluntarily, outside catalog scope* — + never as a non-conformance. This review's own survey was estate-wide over + fourteen counterparts and said nothing about which question it answered. + +A second defect surfaced while fixing the first: the survey decoded peers' +files into flex-auth's own declaration struct, so a peer field sharing a name +with one of flex-auth's in a different shape (`audit-core`'s list-shaped +`emission_guarantee`) made that peer's `layer.yaml` silently disappear. Peers +are now read for `layer` and `role` only. A surveyor's schema is a house rule +too. Receipt: `docs/evidence/2026-09-21-layer-declaration-survey-after-ghdec017.json`. diff --git a/docs/conformance/security-layer-conformance.md b/docs/conformance/security-layer-conformance.md index d943a4c..4ab14c6 100644 --- a/docs/conformance/security-layer-conformance.md +++ b/docs/conformance/security-layer-conformance.md @@ -38,21 +38,44 @@ Assent is to the boundary, recorded at the version where it was given. ## Conformance state at v0.8 -**Conforming, with declared gaps below.** §11's four states: conforming, -blocked-clean, declared gap, undeclared violation. flex-auth claims no -blocked-clean capability and holds no undeclared violation it is aware of. +**Scope of this record:** one repository, `flex-auth`, which is the §4 row +`access-engine`. §11 binds §4 and flex-auth is in it, so this is an in-scope +record. Stated because a run MUST state its scope (A11, `GH-DEC-2026-017` §4). + +**Not conforming on §11, held as a declared gap.** `GH-DEC-2026-018` ruled that +flex-auth is a §4 source of evidence for the decision record and owes an +emission guarantee it did not declare. Recorded in those words, as gate-house +wrote them: flex-auth asked for the unflattering reading, and softening it here +would make the register useless for whoever is next. + +§11's four states: conforming, blocked-clean, declared gap, undeclared +violation. flex-auth claims no blocked-clean capability and holds no undeclared +violation it is aware of. Everything below is a declared gap: owner, blocker, +review date. | # | Gap | Owner | Blocked on | Review | | --- | --- | --- | --- | --- | | G1 | Registry-snapshot digest absent from decision provenance. §9.7.2 promotes it to a conformance prerequisite: a decision that turned on registry content must be replayable from its own record. | `flex-auth` | implementation | `FLEX-WP-0019` | -| G2 | **Emission guarantee not declared.** §11 requires every §4 repository catalogued as a source of evidence to declare class, cadence and detection surface in its machine-readable declaration, and says a source declaring none is not conforming. flex-auth declares none. | `gate-house` to rule, then `flex-auth` | whether flex-auth is a §4 *source of evidence* or only the producer of an artifact `audit-core` sources — see `FLEX-WP-0030` B3 | `FLEX-WP-0030-T04` | +| G2 | **Emission guarantee declared, not delivered.** Ruled 2026-09-21 (`GH-DEC-2026-018`): flex-auth is the source; custody is never source; `audit-core` confirmed its half and declined the role (`AUDIT-IN-0005`). The per-event-class inventory and classification are **published** in [`cadence.yaml`](../../cadence.yaml) and named from `INTENT.md` (`source_of_evidence: true`, `emission_guarantee`). Five decision classes are load-bearing; `deny`, `redact`, `not_applicable`, `audit_only` are **rare** — heartbeat **and** reconciliation, rate monitoring forbidden; `allow` is **volume** — expected-rate **and** reconciliation. Nothing emits yet: no sender is registered with `audit-core`, no outbox, no heartbeat. | `flex-auth` | outbox + heartbeat implementation, and an `audit-core` sender registration (intake + token lane, per `AUDIT-IN-0002`/`0003`) — `FLEX-WP-0031` | **2026-10-19** | | G3 | Published stance-register review is stale: written at two register rows, §13.1 now carries five, and its Finding 1 was ruled by v0.8 §6.4 obligation 3. | `flex-auth` | second edition | `FLEX-WP-0029` | -G2 is recorded as a gap rather than as conformance because the flattering reading -— that `audit-core` is the source and flex-auth merely produces — has not been -confirmed by anyone but flex-auth. §11 says a source that declares no emission -guarantee is not conforming; until the boundary is ruled, the conservative entry -is the honest one. +**G2 fails closed.** A stream with no delivered cadence produces no silence +finding, so the failure on its distinguishing case is a **missed detection**, +never a manufactured permission (A-17, §9.6's §8-asymmetry). That is why it can +be held as a gap rather than being a permission. It is still non-conformance, +tracked, and it is not closed by the classification being published: a +declaration of a guarantee nobody delivers is the §9.1 defect this standard +keeps correcting. + +**What the classification is.** flex-auth's, published as §11 requires the +source to publish it. `GH-DEC-2026-018` §4 declined to classify on flex-auth's +behalf because §11 forbids inferring class from event name, payload or observed +rate, and that binds a ruling as hard as a runner. A conformance run is supplied +this inventory and must not derive it. + +**Atomicity is pending, not waived.** Whether decision-record emission must be +atomic with the decision (§9.4) was expressly left open by `GH-DEC-2026-018` +pending this inventory. It now exists; the question goes to `FLEX-WP-0031`. ## Not gaps @@ -65,8 +88,11 @@ is the honest one. ## How this file is kept true -`internal/layer` asserts that `INTENT.md` carries no `standard_version` and names -a `conformance_record` that exists on disk. That is the same property flex-auth +`internal/layer` asserts that `INTENT.md` carries no `standard_version`, names +a `conformance_record` that exists on disk, states `source_of_evidence`, and — +because it is `true` — names an `emission_guarantee` that exists on disk and +classifies every event class, with every rare load-bearing class carrying +heartbeat and reconciliation and forbidding rate monitoring. That is the same property flex-auth praised in `ops-warden`'s stance map: the published artifact is asserted equal to the shipped one by test, rather than merely written down. It does **not** assert the contents below the declaration — a reviewer still has to read this file. diff --git a/docs/evidence/2026-09-21-layer-declaration-survey-after-ghdec017.json b/docs/evidence/2026-09-21-layer-declaration-survey-after-ghdec017.json new file mode 100644 index 0000000..0c6de44 --- /dev/null +++ b/docs/evidence/2026-09-21-layer-declaration-survey-after-ghdec017.json @@ -0,0 +1,526 @@ +{ + "checks_only": "the closed four-token §3 vocabulary, ASCII case folded per GH-DEC-2026-017 §2; no flex-auth house rules applied to peers", + "derived_at": "run time", + "off_vocab": null, + "root": "/home/worsch", + "rows": [ + { + "Repo": "approval-engine", + "Intent": { + "Source": "approval-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "approval-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "audit-core", + "Intent": { + "Source": "audit-core/INTENT.md", + "Layer": "Engine", + "Role": "Evidence", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "audit-core/layer.yaml", + "Layer": "engine", + "Role": "evidence", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "flex-auth", + "Intent": { + "Source": "flex-auth/INTENT.md", + "Layer": "Engine", + "Role": "PDP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "", + "Layer": "", + "Role": "", + "Found": false, + "InVocabulary": false, + "Canonical": "" + }, + "InCatalog": true + }, + { + "Repo": "gate-house", + "Intent": { + "Source": "gate-house/INTENT.md", + "Layer": "Staff", + "Role": "—", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "File": { + "Source": "", + "Layer": "", + "Role": "", + "Found": false, + "InVocabulary": false, + "Canonical": "" + }, + "InCatalog": true + }, + { + "Repo": "key-cape", + "Intent": { + "Source": "", + "Layer": "", + "Role": "", + "Found": false, + "InVocabulary": false, + "Canonical": "" + }, + "File": { + "Source": "", + "Layer": "", + "Role": "", + "Found": false, + "InVocabulary": false, + "Canonical": "" + }, + "InCatalog": true + }, + { + "Repo": "kings-guard", + "Intent": { + "Source": "kings-guard/INTENT.md", + "Layer": "Staff", + "Role": "", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "File": { + "Source": "kings-guard/layer.yaml", + "Layer": "staff", + "Role": "", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "InCatalog": true + }, + { + "Repo": "maturity-engine", + "Intent": { + "Source": "maturity-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "maturity-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "net-kingdom", + "Intent": { + "Source": "", + "Layer": "", + "Role": "", + "Found": false, + "InVocabulary": false, + "Canonical": "" + }, + "File": { + "Source": "", + "Layer": "", + "Role": "", + "Found": false, + "InVocabulary": false, + "Canonical": "" + }, + "InCatalog": true + }, + { + "Repo": "ops-mason", + "Intent": { + "Source": "", + "Layer": "", + "Role": "", + "Found": false, + "InVocabulary": false, + "Canonical": "" + }, + "File": { + "Source": "", + "Layer": "", + "Role": "", + "Found": false, + "InVocabulary": false, + "Canonical": "" + }, + "InCatalog": true + }, + { + "Repo": "ops-warden", + "Intent": { + "Source": "ops-warden/INTENT.md", + "Layer": "Staff", + "Role": "", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "File": { + "Source": "ops-warden/layer.yaml", + "Layer": "staff", + "Role": "", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "InCatalog": true + }, + { + "Repo": "secrets-engine", + "Intent": { + "Source": "secrets-engine/INTENT.md", + "Layer": "Engine", + "Role": "Lifecycle", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "secrets-engine/layer.yaml", + "Layer": "engine", + "Role": "lifecycle", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "tenant-engine", + "Intent": { + "Source": "tenant-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "tenant-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "user-engine", + "Intent": { + "Source": "user-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "user-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "zone-engine", + "Intent": { + "Source": "zone-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "zone-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + } + ], + "scope": { + "Name": "estate-wide", + "Statement": "every repository surveyed, catalogued or not. Repositories outside §4 are reported as declared voluntarily, outside catalog scope — never as §11 non-conformances.", + "Repos": [ + "approval-engine", + "audit-core", + "flex-auth", + "gate-house", + "key-cape", + "kings-guard", + "maturity-engine", + "net-kingdom", + "ops-mason", + "ops-warden", + "secrets-engine", + "tenant-engine", + "user-engine", + "zone-engine" + ] + }, + "self_disagreeing": [ + { + "Repo": "approval-engine", + "Intent": { + "Source": "approval-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "approval-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "audit-core", + "Intent": { + "Source": "audit-core/INTENT.md", + "Layer": "Engine", + "Role": "Evidence", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "audit-core/layer.yaml", + "Layer": "engine", + "Role": "evidence", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "kings-guard", + "Intent": { + "Source": "kings-guard/INTENT.md", + "Layer": "Staff", + "Role": "", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "File": { + "Source": "kings-guard/layer.yaml", + "Layer": "staff", + "Role": "", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "InCatalog": true + }, + { + "Repo": "maturity-engine", + "Intent": { + "Source": "maturity-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "maturity-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "ops-warden", + "Intent": { + "Source": "ops-warden/INTENT.md", + "Layer": "Staff", + "Role": "", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "File": { + "Source": "ops-warden/layer.yaml", + "Layer": "staff", + "Role": "", + "Found": true, + "InVocabulary": true, + "Canonical": "Staff" + }, + "InCatalog": true + }, + { + "Repo": "secrets-engine", + "Intent": { + "Source": "secrets-engine/INTENT.md", + "Layer": "Engine", + "Role": "Lifecycle", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "secrets-engine/layer.yaml", + "Layer": "engine", + "Role": "lifecycle", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "tenant-engine", + "Intent": { + "Source": "tenant-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "tenant-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "user-engine", + "Intent": { + "Source": "user-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "user-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + }, + { + "Repo": "zone-engine", + "Intent": { + "Source": "zone-engine/INTENT.md", + "Layer": "Engine", + "Role": "PIP", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "File": { + "Source": "zone-engine/layer.yaml", + "Layer": "engine", + "Role": "pip", + "Found": true, + "InVocabulary": true, + "Canonical": "Engine" + }, + "InCatalog": true + } + ], + "spellings": { + "Engine": [ + "approval-engine/INTENT.md", + "audit-core/INTENT.md", + "flex-auth/INTENT.md", + "maturity-engine/INTENT.md", + "secrets-engine/INTENT.md", + "tenant-engine/INTENT.md", + "user-engine/INTENT.md", + "zone-engine/INTENT.md" + ], + "Staff": [ + "gate-house/INTENT.md", + "kings-guard/INTENT.md", + "ops-warden/INTENT.md" + ], + "engine": [ + "approval-engine/layer.yaml", + "audit-core/layer.yaml", + "maturity-engine/layer.yaml", + "secrets-engine/layer.yaml", + "tenant-engine/layer.yaml", + "user-engine/layer.yaml", + "zone-engine/layer.yaml" + ], + "staff": [ + "kings-guard/layer.yaml", + "ops-warden/layer.yaml" + ] + }, + "undeclared": [ + "key-cape", + "net-kingdom", + "ops-mason" + ], + "volunteers": null +} diff --git a/internal/layer/conformance.go b/internal/layer/conformance.go index d8dbc74..a8e3111 100644 --- a/internal/layer/conformance.go +++ b/internal/layer/conformance.go @@ -11,13 +11,46 @@ import ( "gopkg.in/yaml.v3" ) -// Layer vocabulary from the Security Layer Model §3. -var validLayers = map[string]bool{ - "Staff": true, - "Engine": true, - "Tooling": true, +// Layer vocabulary from the Security Layer Model §3, as stated once and closed +// by amendment A9 (GH-DEC-2026-017 §2 and §3). +// +// It has FOUR tokens, not three. The earlier set here admitted +// {Staff, Engine, Tooling} and rejected Taxonomy — which §3.1 defines, §4 +// catalogues twice (info-tech-canon, net-kingdom), and the standard itself is +// an instance of. That set was built from §4's role-typed catalog rows rather +// than from §3's layer table, so it inherited §4's shape and lost §3's fourth +// row. railiance-master's `Taxonomy` was conforming the whole time and this +// validator was the divergent artifact; see docs/conformance/boundaries-review.md. +// +// The spelling below is §4's Layer column form, which GH-DEC-2026-017 §2 makes +// canonical. §3's table heading reads `Engines`, plural; the token is `Engine`. +// Comparison is ASCII case-insensitive and a run MUST fold case before +// comparing (A9): two spellings of Engine do not describe two boundaries, and a +// check that reports nine findings about capital letters has buried the one +// real disagreement it exists to find. +var canonicalLayers = []string{"Taxonomy", "Tooling", "Engine", "Staff"} + +// foldedLayers maps the ASCII-case-folded form of each token to its canonical +// §4 spelling. +var foldedLayers = func() map[string]string { + m := make(map[string]string, len(canonicalLayers)) + for _, l := range canonicalLayers { + m[strings.ToLower(l)] = l + } + return m +}() + +// CanonicalLayer folds ASCII case and returns the §4 column spelling of a +// declared layer value, plus whether the value is in the §3 vocabulary at all. +// A lowercase declaration is conforming, not tolerated (GH-DEC-2026-017 §2). +func CanonicalLayer(declared string) (string, bool) { + canon, ok := foldedLayers[strings.ToLower(strings.TrimSpace(declared))] + return canon, ok } +// Vocabulary returns the four §3 tokens in their canonical spelling. +func Vocabulary() []string { return append([]string(nil), canonicalLayers...) } + // Engine roles from §3.3. An Engine declaration must state one. var validEngineRoles = map[string]bool{ "PDP": true, @@ -34,11 +67,11 @@ var toolingPatterns = []*regexp.Regexp{ // Declaration is the machine-readable §11 form carried in INTENT.md frontmatter. type Declaration struct { - Layer string `yaml:"layer"` - Role string `yaml:"role"` - Framework string `yaml:"framework"` - DeclaredBy string `yaml:"declared_by"` - DeclaredAt string `yaml:"declared_at"` + Layer string `yaml:"layer"` + Role string `yaml:"role"` + Framework string `yaml:"framework"` + DeclaredBy string `yaml:"declared_by"` + DeclaredAt string `yaml:"declared_at"` // ConformanceRecord points at the derived, version-stamped conformance state. // The declaration itself is a boundary and carries no standard version. ConformanceRecord string `yaml:"conformance_record"` @@ -48,6 +81,18 @@ type Declaration struct { StandardVersion string `yaml:"standard_version"` PepStance any `yaml:"pep_stance"` ToolingContacts []any `yaml:"tooling_contacts"` + // SourceOfEvidence records whether §4 marks this repository as a source of + // evidence (A10). GH-DEC-2026-018 §2 rules that flex-auth is one: custody is + // never source, and the decision record is emitted by the repository that + // renders the decision. + SourceOfEvidence *bool `yaml:"source_of_evidence"` + // EmissionGuarantee names the per-event-class emission declaration §11 + // requires of a marked source. GH-DEC-2026-018 §3 rules that the declaration + // is PER EVENT CLASS, not per repository: one guarantee averaged over a + // stream holding both a high-volume allow and a rare deny is not a + // declaration, and would be satisfied by rate monitoring that cannot see the + // deny go missing. + EmissionGuarantee string `yaml:"emission_guarantee"` } // Check parses INTENT.md, asserts the Engine/PDP declaration, and scans @@ -60,6 +105,17 @@ func Check(root string) error { if err := ValidateDeclaration(decl); err != nil { return err } + for _, named := range []struct{ field, path string }{ + {"conformance_record", decl.ConformanceRecord}, + {"emission_guarantee", decl.EmissionGuarantee}, + } { + if strings.TrimSpace(named.path) == "" { + continue + } + if _, err := os.Stat(filepath.Join(root, named.path)); err != nil { + return fmt.Errorf("%s names %q, which is not on disk: §11's derived-artifact rule needs the artifact, not the pointer", named.field, named.path) + } + } hits, err := ScanToolingClients(root) if err != nil { return err @@ -95,13 +151,14 @@ func parseDeclarationYAML(doc string) (Declaration, error) { // ValidateDeclaration asserts §3 vocabulary and Engine-role presence. func ValidateDeclaration(decl Declaration) error { - if !validLayers[decl.Layer] { - return fmt.Errorf("layer %q is not in the §3 vocabulary (Staff, Engine, Tooling)", decl.Layer) + canon, ok := CanonicalLayer(decl.Layer) + if !ok { + return fmt.Errorf("layer %q is not in the §3 vocabulary (%s); the vocabulary is closed and a fifth layer arrives by amending §3, not by declaring one", decl.Layer, strings.Join(canonicalLayers, ", ")) } - if decl.Layer == "Engine" && !validEngineRoles[decl.Role] { + if canon == "Engine" && !validEngineRoles[decl.Role] { return fmt.Errorf("Engine declaration must state role PDP or PIP; got %q", decl.Role) } - if decl.Layer != "Engine" && strings.TrimSpace(decl.Role) != "" { + if canon != "Engine" && strings.TrimSpace(decl.Role) != "" { return fmt.Errorf("layer %q must not state an Engine role", decl.Layer) } if len(decl.ToolingContacts) > 0 { @@ -116,6 +173,15 @@ func ValidateDeclaration(decl Declaration) error { if strings.TrimSpace(decl.ConformanceRecord) == "" { return fmt.Errorf("layer declaration must name a conformance_record: §11 requires a derived artifact to carry the version it was derived at") } + if decl.SourceOfEvidence == nil { + return fmt.Errorf("layer declaration must state source_of_evidence: §4 marks it (A10) and §11's emission check may not infer it — GH-DEC-2026-018 §5") + } + if *decl.SourceOfEvidence && strings.TrimSpace(decl.EmissionGuarantee) == "" { + return fmt.Errorf("a §4 evidence source must name an emission_guarantee declaration, per event class: §11, GH-DEC-2026-018 §3") + } + if !*decl.SourceOfEvidence && strings.TrimSpace(decl.EmissionGuarantee) != "" { + return fmt.Errorf("emission_guarantee is declared but source_of_evidence is false") + } return nil } diff --git a/internal/layer/conformance_test.go b/internal/layer/conformance_test.go index 77df515..75b59c4 100644 --- a/internal/layer/conformance_test.go +++ b/internal/layer/conformance_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/netkingdom/flex-auth/internal/layer" + "gopkg.in/yaml.v3" ) func TestLayerDeclarationConforms(t *testing.T) { @@ -40,6 +41,67 @@ func TestLayerDeclarationConforms(t *testing.T) { if _, err := os.Stat(filepath.Join(repoRoot(t), decl.ConformanceRecord)); err != nil { t.Fatalf("conformance_record %q does not exist: %v", decl.ConformanceRecord, err) } + // GH-DEC-2026-018: flex-auth is the §4 source of evidence for the decision + // record and owes a per-event-class emission guarantee. The declaration + // must say so and must name the published inventory. + if decl.SourceOfEvidence == nil || !*decl.SourceOfEvidence { + t.Fatal("source_of_evidence must be true: GH-DEC-2026-018 §2") + } + if decl.EmissionGuarantee == "" { + t.Fatal("emission_guarantee is empty; §11 requires it of a §4 evidence source") + } + if _, err := os.Stat(filepath.Join(repoRoot(t), decl.EmissionGuarantee)); err != nil { + t.Fatalf("emission_guarantee %q does not exist: %v", decl.EmissionGuarantee, err) + } +} + +// The per-class rule is the operative half of GH-DEC-2026-018 §3: a single +// repository-level guarantee over a stream carrying both a high-volume allow +// and a rare deny is an average, not a declaration. Assert the published +// inventory actually classifies each class, so a later edit cannot collapse it +// back into one number. +func TestEmissionInventoryIsPerEventClass(t *testing.T) { + root := repoRoot(t) + body, err := os.ReadFile(filepath.Join(root, "cadence.yaml")) + if err != nil { + t.Fatal(err) + } + var doc struct { + Source string `yaml:"source"` + Classes map[string]struct { + Action string `yaml:"action"` + EvidenceClass string `yaml:"evidence_class"` + Rarity string `yaml:"rarity"` + RateMonitoring string `yaml:"rate_monitoring"` + Detection []string `yaml:"detection"` + } `yaml:"classes"` + } + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatal(err) + } + if len(doc.Classes) < 2 { + t.Fatal("cadence.yaml declares fewer than two event classes; §11 requires the guarantee per class, not per repository") + } + for name, c := range doc.Classes { + if c.Action == "" || c.EvidenceClass == "" || c.Rarity == "" { + t.Errorf("class %q: action, evidence_class and rarity must all be published — a run may not infer them (§11)", name) + } + // A rare load-bearing class MUST carry heartbeat AND reconciliation and + // MUST NOT be covered by rate monitoring. + if c.EvidenceClass == "load-bearing" && c.Rarity == "rare" { + if c.RateMonitoring != "forbidden" { + t.Errorf("class %q is rare load-bearing; rate_monitoring must be forbidden", name) + } + var heartbeat, reconciliation bool + for _, d := range c.Detection { + heartbeat = heartbeat || d == "heartbeat" + reconciliation = reconciliation || d == "reconciliation" + } + if !heartbeat || !reconciliation { + t.Errorf("class %q is rare load-bearing; it must carry heartbeat AND reconciliation, not either alone", name) + } + } + } } func TestVersionPinInDeclarationIsRejected(t *testing.T) { @@ -67,6 +129,71 @@ func TestUnknownLayerIsRejected(t *testing.T) { } } +// The defect this validator carried: the vocabulary has FOUR tokens and this +// set admitted three, omitting the layer the standard itself occupies. +// railiance-master's `Taxonomy` was conforming and the checker was wrong. +func TestVocabularyHasFourTokensIncludingTaxonomy(t *testing.T) { + want := map[string]bool{"Taxonomy": true, "Tooling": true, "Engine": true, "Staff": true} + got := layer.Vocabulary() + if len(got) != len(want) { + t.Fatalf("vocabulary = %v; want the four §3 tokens", got) + } + for _, tok := range got { + if !want[tok] { + t.Errorf("unexpected token %q", tok) + } + } + if canon, ok := layer.CanonicalLayer("Taxonomy"); !ok || canon != "Taxonomy" { + t.Fatal("Taxonomy was rejected: §3.1 defines it, §4 catalogues it twice, and the standard is an instance of it") + } +} + +// GH-DEC-2026-017 §2: comparison is ASCII case-insensitive and a run MUST fold +// before comparing. A lowercase declaration is conforming, not tolerated. +func TestVocabularyComparisonFoldsCase(t *testing.T) { + for _, in := range []string{"engine", "ENGINE", "Engine", " engine "} { + canon, ok := layer.CanonicalLayer(in) + if !ok { + t.Fatalf("%q was rejected; comparison must fold ASCII case", in) + } + // §4's column form is canonical, so the folded result reports as `Engine` + // however the declaration spelled it. + if canon != "Engine" { + t.Fatalf("CanonicalLayer(%q) = %q; want the §4 column spelling Engine", in, canon) + } + } + if err := layer.ValidateDeclaration(layer.Declaration{ + Layer: "engine", Role: "PDP", + ConformanceRecord: "docs/conformance/security-layer-conformance.md", + SourceOfEvidence: boolPtr(true), EmissionGuarantee: "cadence.yaml", + }); err != nil { + t.Fatalf("a lowercase declaration was rejected: %v", err) + } +} + +// §3's table heading reads `Engines`, plural, while §4's column reads `Engine`. +// A9 states the token once and it is §4's. A declaration of `Engines` is a +// declaration of a token the vocabulary does not have. +func TestPluralEnginesIsNotTheToken(t *testing.T) { + if _, ok := layer.CanonicalLayer("Engines"); ok { + t.Fatal("`Engines` was admitted; the token is `Engine`, as §4's Layer column carries it") + } +} + +// A §4 evidence source that names no emission guarantee is not conforming. +func TestEvidenceSourceWithoutEmissionGuaranteeIsRejected(t *testing.T) { + err := layer.ValidateDeclaration(layer.Declaration{ + Layer: "Engine", Role: "PDP", + ConformanceRecord: "docs/conformance/security-layer-conformance.md", + SourceOfEvidence: boolPtr(true), + }) + if err == nil { + t.Fatal("a marked evidence source with no emission_guarantee was accepted") + } +} + +func boolPtr(b bool) *bool { return &b } + func repoRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) diff --git a/internal/layer/survey.go b/internal/layer/survey.go index 648a93d..aedb5da 100644 --- a/internal/layer/survey.go +++ b/internal/layer/survey.go @@ -6,6 +6,8 @@ import ( "path/filepath" "sort" "strings" + + "gopkg.in/yaml.v3" ) // Form is one of the two shapes §11 accepts for a declaration: a layer: key in @@ -15,10 +17,14 @@ type Form struct { Layer string // the layer: value exactly as written Role string Found bool - // InVocabulary reports whether Layer is in the §3 vocabulary as written. - // Deliberately case-sensitive: whether §3 is case-insensitive is the open - // question (FLEX-WP-0030 B1), and folding case here would hide it. + // InVocabulary reports whether Layer is in the §3 vocabulary after ASCII + // case folding. GH-DEC-2026-017 §2 ruled comparison case-insensitive and + // requires a run to fold before comparing, so a lowercase declaration is + // conforming rather than tolerated. The earlier field was deliberately + // case-sensitive while that was the open question; it is now answered. InVocabulary bool + // Canonical is the §4 column spelling of Layer, empty when out of vocabulary. + Canonical string } // SurveyRow is one repository's declaration as observed from outside. It holds @@ -28,20 +34,37 @@ type SurveyRow struct { Repo string Intent Form // INTENT.md frontmatter File Form // layer.yaml or equivalent + // InCatalog reports whether this repository is a §4 row. §11 binds §4, so a + // row that is false can be a volunteer but cannot be a §11 non-conformance. + InCatalog bool } // Declared reports whether any machine-readable form was found. func (r SurveyRow) Declared() bool { return r.Intent.Found || r.File.Found } // SelfDisagrees reports a repository whose two §11 forms state different layer -// values. Case counts: that is the point of the finding, not an artifact of it. +// values as written. Under GH-DEC-2026-017 §1 a disagreement between the two +// forms is a finding IN ITS OWN RIGHT and MUST be reported rather than resolved +// away by precedence: precedence says which value is the repository's answer, +// it does not say the disagreement did not happen. So case still counts here. func (r SurveyRow) SelfDisagrees() bool { return r.Intent.Found && r.File.Found && r.Intent.Layer != r.File.Layer } -// Layer returns the value to report, preferring INTENT.md, which §11 names -// first. The preference is this survey's, not a ruling — which form governs is -// exactly what FLEX-WP-0030 B1 asks. +// DisagreesOnLayer reports the stronger case: the two forms name different +// LAYERS, not merely different spellings of one. Case-folded per A9. Nine of +// the estate's nine self-disagreements are spelling only, and none of them is +// this. +func (r SurveyRow) DisagreesOnLayer() bool { + return r.Intent.Found && r.File.Found && r.Intent.Canonical != r.File.Canonical +} + +// Layer returns the repository's answer. INTENT.md governs: GH-DEC-2026-017 §1 +// rules that a layer.yaml or equivalent is a DERIVED artifact under §11's own +// derived-artifact rule — marked derived, naming INTENT.md, required to agree — +// and that the declaration-form paragraph gave the declaration a machine-readable +// FORM, not a second AUTHORITY. That is now a ruling, no longer this survey's +// preference. func (r SurveyRow) Layer() string { if r.Intent.Found { return r.Intent.Layer @@ -51,6 +74,74 @@ func (r SurveyRow) Layer() string { var declarationFiles = []string{"layer.yaml", "layer.yml"} +// catalogRows are §4's layer-catalog repositories. §11's obligations attach to +// estate-authored repositories IN §4; a repository outside it may declare +// voluntarily, and a run that grades a volunteer is over-scoped +// (GH-DEC-2026-017 §4, amendment A11). `OpenBao` is a §4 row but is not +// estate-authored, so no declaration is owed there. +var catalogRows = map[string]bool{ + "info-tech-canon": true, "net-kingdom": true, "key-cape": true, + "user-engine": true, "tenant-engine": true, "zone-engine": true, + "secrets-engine": true, "audit-core": true, "access-engine": true, + "approval-engine": true, "maturity-engine": true, "gate-house": true, + "ops-mason": true, "ops-warden": true, "kings-guard": true, + "whitehat-security": true, +} + +// catalogAliases maps a checkout directory to the §4 row it is. The rename to +// access-engine is ruled and sequenced (FLEX-DEC-2026-013, FLEX-WP-0020) and +// has not landed; §4's own note says both names denote the same authority until +// it does. +var catalogAliases = map[string]string{"flex-auth": "access-engine"} + +// InCatalog reports whether a checkout directory is a §4 catalog row. +func InCatalog(repo string) bool { + if alias, ok := catalogAliases[repo]; ok { + repo = alias + } + return catalogRows[repo] +} + +// Scope is what a conformance run ranged over. +// +// §11 as amended by A11 requires a run to STATE its scope: a run over §4 and a +// run over every repository carrying a declaration answer different questions, +// and a report that does not say which it did cannot be acted on. That is the +// same defect as a survey that did not record which file a value came from — +// the defect this survey was built to remove — one level up. +type Scope struct { + Name string + Statement string + Repos []string +} + +// CatalogScope is a run over the §4 catalog: the set §11's obligations bind. +func CatalogScope(repos []string) Scope { + var in []string + for _, r := range repos { + if InCatalog(r) { + in = append(in, r) + } + } + return Scope{ + Name: "§4 catalog", + Statement: "estate-authored repositories catalogued in §4. §11's obligations attach here, and only here.", + Repos: in, + } +} + +// EstateScope is a run over every repository asked, catalogued or not. It +// answers a different question from CatalogScope and says so: a repository +// outside §4 that declares is reported as a volunteer, never as a §11 +// non-conformance. +func EstateScope(repos []string) Scope { + return Scope{ + Name: "estate-wide", + Statement: "every repository surveyed, catalogued or not. Repositories outside §4 are reported as declared voluntarily, outside catalog scope — never as §11 non-conformances.", + Repos: append([]string(nil), repos...), + } +} + // SurveyDeclarations reads both §11 forms for every repository under root. // // It checks ONE property: whether each layer: value sits in the §3 vocabulary @@ -71,6 +162,7 @@ func SurveyDeclarations(root string, repos []string) ([]SurveyRow, error) { break } } + row.InCatalog = InCatalog(repo) rows = append(rows, row) } sort.Slice(rows, func(i, j int) bool { return rows[i].Repo < rows[j].Repo }) @@ -86,25 +178,45 @@ func readForm(root, rel string) Form { if err != nil || strings.TrimSpace(decl.Layer) == "" { return Form{} } + canon, ok := CanonicalLayer(decl.Layer) return Form{ Source: rel, Layer: decl.Layer, Role: decl.Role, Found: true, - InVocabulary: validLayers[decl.Layer], + InVocabulary: ok, + Canonical: canon, } } +// peerDeclaration is the ONLY shape the survey reads from another repository: +// layer and role. Decoding a peer into flex-auth's own Declaration applied +// flex-auth's field types to someone else's file — and when flex-auth's +// emission_guarantee became a path string, audit-core's list-shaped +// emission_guarantee failed to decode and its layer.yaml silently vanished +// from the survey. A surveyor's schema is a house rule too. +type peerDeclaration struct { + Layer string `yaml:"layer"` + Role string `yaml:"role"` +} + // loadAnyDeclaration reads INTENT.md frontmatter or a bare declaration file. -func loadAnyDeclaration(path string) (Declaration, error) { - if strings.HasSuffix(path, ".md") { - return LoadDeclaration(path) - } +func loadAnyDeclaration(path string) (peerDeclaration, error) { body, err := os.ReadFile(path) if err != nil { - return Declaration{}, err + return peerDeclaration{}, err } - return parseDeclarationYAML(string(body)) + doc := string(body) + if strings.HasSuffix(path, ".md") { + if doc, err = splitFrontmatter(doc); err != nil { + return peerDeclaration{}, err + } + } + var decl peerDeclaration + if err := yaml.Unmarshal([]byte(doc), &decl); err != nil { + return peerDeclaration{}, fmt.Errorf("parse layer declaration %s: %w", path, err) + } + return decl, nil } // Spellings groups every observed layer: value across both forms, so the spread @@ -149,17 +261,38 @@ func SelfDisagreeing(rows []SurveyRow) []SurveyRow { return out } -// FormatSurvey renders the survey as a stable, diffable report. -func FormatSurvey(rows []SurveyRow) string { +// VolunteerDeclarations lists repositories outside §4 that declared anyway. The +// correct report for them is "declared voluntarily, outside catalog scope" +// (GH-DEC-2026-017 §4): treating a volunteer's declaration as a non-conformance +// is both wrong about who §11 binds and the fastest way to stop getting +// volunteers. +func VolunteerDeclarations(rows []SurveyRow) []string { + var out []string + for _, r := range rows { + if !r.InCatalog && r.Declared() { + out = append(out, r.Repo) + } + } + return out +} + +// FormatSurvey renders the survey as a stable, diffable report. It leads with +// the scope because §11 requires a run to state what it ranged over. +func FormatSurvey(scope Scope, rows []SurveyRow) string { var b strings.Builder + fmt.Fprintf(&b, "Scope: %s — %s\n\n", scope.Name, scope.Statement) fmt.Fprintf(&b, "%-18s %-16s %-16s %s\n", "REPO", "INTENT.md", "DECL FILE", "NOTE") for _, r := range rows { note := "" switch { + case !r.Declared() && !r.InCatalog: + note = "no declaration; outside §4 — none owed" case !r.Declared(): note = "NO DECLARATION (§11)" + case r.DisagreesOnLayer(): + note = "forms name DIFFERENT LAYERS; INTENT.md governs" case r.SelfDisagrees(): - note = "forms disagree" + note = "forms disagree on spelling only; INTENT.md governs, still a finding" case !r.Intent.Found: note = "declaration file only" case !r.File.Found: @@ -167,9 +300,12 @@ func FormatSurvey(rows []SurveyRow) string { } for _, f := range []Form{r.Intent, r.File} { if f.Found && !f.InVocabulary { - note += "; outside §3 vocabulary as written" + note += "; " + f.Layer + " is outside the closed §3 vocabulary" } } + if r.Declared() && !r.InCatalog { + note += "; declared voluntarily, outside §4 catalog scope" + } fmt.Fprintf(&b, "%-18s %-16s %-16s %s\n", r.Repo, dash(r.Intent.Layer), dash(r.File.Layer), note) } return b.String() diff --git a/internal/layer/survey_test.go b/internal/layer/survey_test.go index 0e6b42f..9f11346 100644 --- a/internal/layer/survey_test.go +++ b/internal/layer/survey_test.go @@ -48,15 +48,82 @@ func TestSurveyDetectsFormsDisagreeingWithinOneRepo(t *testing.T) { } } -// Case is not folded: whether §3 is case-insensitive is the open question, and -// folding here would hide the finding rather than resolve it. -func TestSurveyDoesNotFoldCase(t *testing.T) { +// Case IS folded now. GH-DEC-2026-017 §2 ruled comparison ASCII case-insensitive +// and requires a run to fold before comparing. This test was the inverse +// assertion while that was the open question; it is inverted rather than +// deleted, so the ruling is visible in the place the old behaviour lived. +func TestSurveyFoldsCaseAfterGHDEC017(t *testing.T) { root := t.TempDir() writeRepo(t, root, "peer", "", "engine") rows, _ := layer.SurveyDeclarations(root, []string{"peer"}) - if rows[0].File.InVocabulary { - t.Fatal(`"engine" was accepted into the §3 vocabulary; the survey must not fold case`) + if !rows[0].File.InVocabulary { + t.Fatal(`"engine" was rejected; a lowercase declaration is conforming, not tolerated`) + } + if rows[0].File.Canonical != "Engine" { + t.Fatalf("Canonical = %q; want the §4 column spelling Engine", rows[0].File.Canonical) + } +} + +// The two forms disagreeing only in spelling is still reported — precedence +// says which value is the repository's answer, it does not say the +// disagreement did not happen (GH-DEC-2026-017 §1) — but it is not a +// disagreement about a LAYER. +func TestSpellingDisagreementIsReportedButIsNotALayerDisagreement(t *testing.T) { + root := t.TempDir() + writeRepo(t, root, "peer", "Engine", "engine") + + rows, _ := layer.SurveyDeclarations(root, []string{"peer"}) + if !rows[0].SelfDisagrees() { + t.Fatal("the form disagreement was not reported; it is a finding in its own right") + } + if rows[0].DisagreesOnLayer() { + t.Fatal("Engine vs engine was reported as a disagreement about a layer; two spellings of Engine do not describe two boundaries") + } + if rows[0].Layer() != "Engine" { + t.Fatal("INTENT.md governs; the repository's answer is its INTENT.md value") + } +} + +// Taxonomy is in the vocabulary, and railiance-master — which declares it and +// is not a §4 row — is a volunteer, not a non-conformance. +func TestTaxonomyVolunteerIsInVocabularyAndOutOfScope(t *testing.T) { + root := t.TempDir() + writeRepo(t, root, "railiance-master", "Taxonomy", "") + + rows, _ := layer.SurveyDeclarations(root, []string{"railiance-master"}) + if !rows[0].Intent.InVocabulary { + t.Fatal("Taxonomy was rejected: §3.1 defines it and §4 catalogues it twice") + } + if rows[0].InCatalog { + t.Fatal("railiance-master was treated as a §4 catalog row") + } + if got := layer.VolunteerDeclarations(rows); len(got) != 1 || got[0] != "railiance-master" { + t.Fatalf("VolunteerDeclarations = %v; want [railiance-master]", got) + } +} + +// §11 binds §4, and A11 requires a run to state what it ranged over. +func TestRunStatesItsScope(t *testing.T) { + repos := []string{"flex-auth", "gate-house", "railiance-master"} + + catalog := layer.CatalogScope(repos) + if catalog.Statement == "" { + t.Fatal("a scope with no statement cannot be acted on") + } + if len(catalog.Repos) != 2 { + t.Fatalf("CatalogScope = %v; want the two §4 rows (flex-auth is the access-engine row)", catalog.Repos) + } + if !layer.InCatalog("flex-auth") || !layer.InCatalog("access-engine") { + t.Fatal("the §4 row resolves under both names until the ruled rename lands") + } + if layer.InCatalog("railiance-master") { + t.Fatal("railiance-master is not a §4 row") + } + + estate := layer.EstateScope(repos) + if len(estate.Repos) != 3 || estate.Name == catalog.Name { + t.Fatal("the estate-wide run and the §4 run must be distinguishable; they answer different questions") } } @@ -87,3 +154,21 @@ func TestSurveySingleFormIsNotDisagreement(t *testing.T) { t.Fatal(`"Engine" was rejected from the §3 vocabulary`) } } + +// A peer's declaration is read for layer and role only. A peer carrying a field +// flex-auth also uses, in a different shape, must not drop out of the survey. +func TestPeerWithForeignFieldShapesIsStillSurveyed(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "audit-core") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "layer: engine\nrole: evidence\nemission_guarantee:\n - id: chain-head-attestation\n" + if err := os.WriteFile(filepath.Join(dir, "layer.yaml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + rows, _ := layer.SurveyDeclarations(root, []string{"audit-core"}) + if !rows[0].File.Found || rows[0].File.Canonical != "Engine" { + t.Fatalf("audit-core's layer.yaml was dropped: %+v", rows[0].File) + } +} diff --git a/tools/survey_layer_declarations.go b/tools/survey_layer_declarations.go index 0eee627..9b80e12 100644 --- a/tools/survey_layer_declarations.go +++ b/tools/survey_layer_declarations.go @@ -38,9 +38,24 @@ var counterparts = []string{ "secrets-engine", "tenant-engine", "user-engine", "zone-engine", } +func filterRows(rows []layer.SurveyRow, keep []string) []layer.SurveyRow { + in := map[string]bool{} + for _, r := range keep { + in[r] = true + } + var out []layer.SurveyRow + for _, r := range rows { + if in[r.Repo] { + out = append(out, r) + } + } + return out +} + func main() { root := flag.String("root", "", "directory holding the repositories (default: home)") jsonOut := flag.String("json", "", "write a receipt to this path") + catalogOnly := flag.Bool("catalog-only", false, "range over the §4 catalog rows alone, the set §11's obligations bind") flag.Parse() dir := *root @@ -57,7 +72,19 @@ func main() { fail(err) } - fmt.Print(layer.FormatSurvey(rows)) + // §11 as amended by A11: a run MUST state its scope. A run over §4 and a + // run over every repository carrying a declaration answer different + // questions, and a report that does not say which it did cannot be acted + // on. This survey is estate-wide by construction — it was built to disagree + // with other repositories' checkers — so it says so and reports + // non-catalogued repositories as volunteers rather than as findings. + scope := layer.EstateScope(counterparts) + if *catalogOnly { + scope = layer.CatalogScope(counterparts) + rows = filterRows(rows, scope.Repos) + } + + fmt.Print(layer.FormatSurvey(scope, rows)) spellings := layer.Spellings(rows) undeclared := layer.Undeclared(rows) @@ -86,9 +113,10 @@ func main() { for _, r := range disagree { fmt.Printf(" %-18s INTENT.md=%-8q %s=%q\n", r.Repo, r.Intent.Layer, r.File.Source, r.File.Layer) } - fmt.Println("\n§11 accepts \"a layer: key in INTENT.md frontmatter, OR an equivalent") - fmt.Println("declaration file\" and does not say which governs when both exist and") - fmt.Println("disagree. This is the open question, not a verdict.") + fmt.Println("\nRuled (GH-DEC-2026-017 §1): INTENT.md governs; a layer.yaml is a derived") + fmt.Println("artifact and MUST agree. The disagreement is still reported, because") + fmt.Println("precedence says which value is the answer, not that the disagreement") + fmt.Println("did not happen. Rows marked 'spelling only' name the same layer.") } var offVocab []string @@ -100,19 +128,25 @@ func main() { } } if len(offVocab) > 0 { - fmt.Printf("\nOutside the §3 vocabulary as written: %s\n", strings.Join(offVocab, ", ")) + fmt.Printf("\nOutside the closed §3 vocabulary (four tokens, case folded): %s\n", strings.Join(offVocab, ", ")) + } + + if vol := layer.VolunteerDeclarations(rows); len(vol) > 0 { + fmt.Printf("\nDeclared voluntarily, outside §4 catalog scope — welcome, and NOT a §11\nnon-conformance: %s\n", strings.Join(vol, ", ")) } if *jsonOut != "" { receipt := map[string]any{ - "derived_at": "run time", - "root": dir, - "rows": rows, - "spellings": spellings, - "undeclared": undeclared, - "off_vocab": offVocab, + "derived_at": "run time", + "scope": scope, + "root": dir, + "rows": rows, + "spellings": spellings, + "undeclared": undeclared, + "off_vocab": offVocab, "self_disagreeing": disagree, - "checks_only": "§3 vocabulary as written; no flex-auth house rules applied to peers", + "volunteers": layer.VolunteerDeclarations(rows), + "checks_only": "the closed four-token §3 vocabulary, ASCII case folded per GH-DEC-2026-017 §2; no flex-auth house rules applied to peers", } b, err := json.MarshalIndent(receipt, "", " ") if err != nil { diff --git a/workplans/FLEX-WP-0030-boundary-declaration-cleanup.md b/workplans/FLEX-WP-0030-boundary-declaration-cleanup.md index e9d678d..87e9a4a 100644 --- a/workplans/FLEX-WP-0030-boundary-declaration-cleanup.md +++ b/workplans/FLEX-WP-0030-boundary-declaration-cleanup.md @@ -267,6 +267,14 @@ Corrections went to `gate-house` and the five engine repositories that received the original, plus `ops-warden`, `kings-guard` and `audit-core`, who are affected by the corrected finding and had not been told of the first. +2026-09-21 (rulings): gate-house ruled B1, B3 and B4 (`GH-DEC-2026-017`, +`-018`, `-019`; amendments A9–A13). B1: `INTENT.md` governs, case folds. B3: +**against flex-auth** — a source of evidence, non-conforming on §11 today, and +`audit-core` confirmed independently (`AUDIT-IN-0005`). B4: ruled as asked. B5: +A13 notes it. B2: `gate-house` declared; `key-cape`, `ops-mason` and +`net-kingdom` have not answered. Task stays `progress` on B2 alone — not closed +by silence. Consequences are T06–T08. + ## Out of scope - Bumping flex-auth to declare v0.8. v0.8 is `status: proposed`; T01 removes the @@ -312,3 +320,65 @@ recorded rather than edited away. 2026-09-21: done. Receipt at `docs/evidence/2026-09-21-layer-declaration-survey.json`. + +## 6. Fix the validator: four tokens, case folded, scope stated + +```task +id: FLEX-WP-0030-T06 +status: done +priority: high +``` + +Owner: `flex-auth`. Authority: `GH-DEC-2026-017` §2–§4, A9, A11. + +The validator admitted `{Staff, Engine, Tooling}` — three tokens taken from §4's +catalog rows, omitting §3.1's Taxonomy. `railiance-master`'s `Taxonomy` was +conforming; the validator was the divergent artifact. + +Gate: the four tokens are admitted; comparison folds ASCII case; §4's column +spelling is canonical; `INTENT.md` governs while form disagreements are still +reported; a run states its scope and reports non-catalogued declarers as +volunteers; the review records the correction rather than editing it away. + +2026-09-21: done. `internal/layer` (`CanonicalLayer`, `Vocabulary`, `Scope`, +`CatalogScope`, `EstateScope`, `VolunteerDeclarations`), survey `--catalog-only`, +eight new or inverted tests. Also fixed a survey defect found on the way: peers +were decoded into flex-auth's struct, and `audit-core`'s `layer.yaml` silently +dropped out once field shapes diverged. Correction in +`docs/conformance/boundaries-review.md`; receipt re-run. + +## 7. Publish the per-class emission inventory and register G2 as a dated gap + +```task +id: FLEX-WP-0030-T07 +status: done +priority: high +``` + +Owner: `flex-auth`. Authority: `GH-DEC-2026-018`. + +Gate: G2 closes as a question with the ruling as its reason and reopens as a +gap with owner, blocker and review date; the classification is published per +event class by flex-auth, not inferred; `INTENT.md` states +`source_of_evidence` and names the declaration; the test suite asserts both. + +2026-09-21: done. `cadence.yaml` classifies five decision classes. Rare +load-bearing (`deny`, `redact`, `not_applicable`, `audit_only`): heartbeat and +reconciliation, rate monitoring forbidden. Volume load-bearing (`allow`): +expected-rate **and** reconciliation, because rate cannot see a targeted subset +removed. G2 is dated 2026-10-19; delivery is `FLEX-WP-0031`. + +## 8. Rule whether resource.system follows the repository or the runtime + +```task +id: FLEX-WP-0030-T08 +status: done +priority: high +``` + +Owner: `flex-auth` as PDP. Asked by `ops-warden` (`WARDEN-IN-0003`). + +2026-09-21: done. `FLEX-DEC-2026-015`: it follows the runtime. It is policy +vocabulary, retained by `FLEX-DEC-2026-013`, and both places the PDP matches on +it fail closed on an unknown value. Any future flip is PDP-first, +consumer-second, and is not a repository-rename step. diff --git a/workplans/FLEX-WP-0031-decision-record-emission.md b/workplans/FLEX-WP-0031-decision-record-emission.md new file mode 100644 index 0000000..ad60b01 --- /dev/null +++ b/workplans/FLEX-WP-0031-decision-record-emission.md @@ -0,0 +1,90 @@ +--- +id: FLEX-WP-0031 +type: workplan +title: "The decision record has a declared emission guarantee and nothing that delivers it" +domain: infotech +repo: flex-auth +status: ready +flavor: implementation +owner: claude +topic_slug: netkingdom +planning_priority: P1 +planning_order: 310 +related_workplans: + - FLEX-WP-0030 + - FLEX-WP-0019 +created: "2026-09-21" +updated: "2026-09-21" +--- + +# FLEX-WP-0031 — Deliver the decision-record emission guarantee + +`GH-DEC-2026-018` ruled that flex-auth is the §4 source of evidence for the +decision record, and that it is not conforming on §11 until it declares and +delivers a per-event-class emission guarantee. The declaration is published +(`cadence.yaml`, `FLEX-WP-0030-T07`). Nothing emits: the decision record reaches +consumers only in the `/v1/check` response, no sender named flex-auth or +`access-engine` is registered with `audit-core`, and there is no outbox, +heartbeat or reconciliation count. This plan closes declared gap **G2** +(`docs/conformance/security-layer-conformance.md`, review 2026-10-19). + +Bound, stated up front so no argument rests on more: heartbeat and +reconciliation detect loss, outage, drain failure and accident. Neither detects +a compromised flex-auth suppressing a record and its own count together. + +## 1. Decide emission atomicity + +```task +id: FLEX-WP-0031-T01 +status: todo +priority: high +``` + +`GH-DEC-2026-018` left open whether decision-record emission must be atomic with +the decision (§9.4), pending the class inventory. It exists now. Decide whether +a rare load-bearing decision (`deny`) may be returned before its record is +committed to the outbox, and record the answer as a `FLEX-DEC`. Gate: decided, +with the latency cost stated. + +## 2. Register flex-auth as an audit-core sender + +```task +id: FLEX-WP-0031-T02 +status: todo +priority: high +``` + +Open an intake with `audit-core` (worked examples `AUDIT-IN-0002`, +`AUDIT-IN-0003`): sender registration, `evidence_kind`, `heartbeat_classes` per +class exactly as `cadence.yaml` publishes them, and a token lane routed via +`warden route find`. audit-core has said it accepts the classification as +supplied and will not infer it. Gate: sender registered; no secret in any file. + +## 3. Transactional outbox, heartbeat and reconciliation counts + +```task +id: FLEX-WP-0031-T03 +status: todo +priority: high +``` + +Emit one event per decision into a local outbox, drained to `audit-core` +`POST /v1/events`; a daily `flex-auth.decision.heartbeat`; committed counts per +class exposed for `GET /v1/reconciliation`; lag bound per `cadence.yaml`. +Gate: `cadence.yaml` validates under the net-kingdom emission-cadence profile +checker with the inventory supplied as `--rare-load-bearing`/`--load-bearing` +assertions; tests assert the declared classes equal the `DecisionEffect` +vocabulary so a new effect cannot ship unclassified. + +## 4. Close G2 + +```task +id: FLEX-WP-0031-T04 +status: todo +priority: medium +``` + +Change `cadence.yaml` `state` to emitting, move G2 out of the gap table with the +evidence, and tell `gate-house`, `audit-core` and `kings-guard`. Gate: a silence +finding is observed on a deliberately withheld heartbeat in a non-production +run.