Skip to content

Latest commit

 

History

History
195 lines (193 loc) · 462 KB

decisions.md

File metadata and controls

195 lines (193 loc) · 462 KB

Decision log

Lightweight, dated operator decisions (D-nnn). ADRs cover architecture; this file covers project/process decisions.

ID Date Decision
D-001 2026-07-21 Started under working title "verdict-2". No public GitHub repo until explicitly created by the operator. Local git only for now. Name resolved by D-009.
D-002 2026-07-21 License Apache-2.0 (Kubernetes/Argo CD precedent: permissive + patent grant; supersedes the initial MIT choice same day). Fully open source; no employer names, internal system names, or internal policy content in any committed artifact.
D-003 2026-07-21 Go module path github.com/PlatformRelay/assent; confirm org (PlatformRelay vs personal) before first push.
D-004 2026-07-21 Git identity for this repo: Konrad Heimel <konrad.heimel@gmail.com> (local git config). No AI co-author trailers.
D-005 2026-07-21 Development is spec/test-driven: openspec workflow, REQ IDs with Test: + Verify:, TDD for all implementation work.
D-006 2026-07-21 Operator preference: Kyverno-style declarative YAML is the primary policy surface; Rego is an escape-hatch predicate backend inside the same envelope, never a parallel frontend (ADR-0002 v2).
D-007 2026-07-21 No database for now: the per-run JSON report artifact is the storage format; stats aggregates report files (ADR-0009/0012).
D-008 2026-07-21 Sample corpus: operator provides 2–3 further real self-service repo shapes (generalized, never verbatim); additionally curate open-source repos as public test/demo corpora (candidates in examples/repos).
D-009 2026-07-21 Project name: assent (chosen from the naming candidates). Folder, module path, CLI (assent), config dir (.assent/), and apiVersion group (assent.dev/v1alpha1) renamed accordingly. Before going public: verify GitHub name availability and the assent.dev domain (fallback: adjust the apiVersion group).
D-010 2026-07-21 Testing bar from day one: TDD mandatory; CI-enforced ≥90% line coverage on internal/… from the first implementation PR; golden decision tests + integration tests + real-forge e2e per ADR-0006. The adopter test format is a first-class public contract (ADR-0014) — easy policy testability for governed repos is a product feature, not tooling.
D-011 2026-07-21 Architecture review cycle completed: adversarial review (15 findings, 2 critical) folded back as ADR-0015 (trust boundaries) + amendments to ADR-0003/0004/0007/0009/0011/0012; assert decided as hybrid CEL-leaf trees on cel-go (ADR-0013, kyverno-json dropped); engineering rules codified in GUIDELINES.md; OSS practices adopted from sibling projects per oss-playbook.md. All new/amended ADRs remain Proposed pending operator review.
D-012 2026-07-21 Adoption-gated scope (second review, part C): v1 = GitLab + run/--dry-run/explain/test/lint + builtin/HTTP+exec providers + assert backend. rego, gRPC, WASM, GitHub adapter, serve, and remote packs stay designed seams that unlock only when a named consumer commits — no speculative frozen contracts for tiers without users. Meta-plan gains an adoption gate: Phase 4 exits only when one real repository has run assent in anger.
D-013 2026-07-21 Second independent review round (roast + security, in the workspace inbox) folded in: provider/plugin trust model, execution-authority matrix, resource limits, OPA capability sandbox, secret redaction, content-keyed FactQuery, finding-lifecycle state machine incl. stale-consent invalidation, onFail branches, per-firing points, scan confusion matrix, lint hard-errors, positions in the change model, CI security gates. Spec-hygiene drift (stale openspec/examples/C4) corrected.
D-014 2026-07-21 Public repo created: github.com/PlatformRelay/assent — resolves D-001/D-003 (org: PlatformRelay, matching the module path). Settings: topics, delete-branch-on-merge, wiki/projects off, dependabot alerts + security updates, secret scanning + push protection enabled. Residual from D-009: assent.dev domain still unverified — apiVersion group may need adjusting before contracts freeze.
D-015 2026-07-21 Presentation direction approved by operator: strong defaults with tiered flexibility per ADR-0016 — config knobs / slot overrides / full templates (tiers 1–2 adoption-gated), CEL-based message interpolation (one expression language), renderer-owned envelope, PresentationModel as pinned render contract (aligned with the design roast's four-record split), render goldens + template lint, locale catalog for chrome strings.
D-016 2026-07-21 Third adversarial review (full design roast) adopted as ADR-0017: merge-result pinning, named required obligations replacing anonymous vouch, require-review split from challenge (acknowledgement ≠ authorization), one-shot arming restrictions for expiring facts, EntryRef governed subjects + matcher domain split, typed/minimized provider protocol, Reconcile publication port, serialized-schemas-are-the-API (no v1 Go SDK), strict end-to-end contract fixture as the Phase-3 gate. Four previously-fixed risks formally refuted by the review; examples migrate to prove/onFailure when schemas land.
D-017 2026-07-21 First named consumer committed (behavioral-compatibility review folded in; disposition in named-consumer-compat.md): a private, production Kafka self-service merge gate — assent's unaffiliated reference use case — is the D-012 named consumer. Accepted at contract level before the Phase-3 freeze: rollout phases off/observe/enforce (OQ-21 lean reversed → P3-E4/ADR-0018), policy profiles + semantic comparison records + PolicyComparisonSuite promotion gates, typed ApprovalEvidence (OQ-23 → P3-E1), database-free marker/reconciliation protocol + per-MR serialization (P3-E5/ADR-0019, preserves D-007), complex-rule-backend contract unlock (E11), one sanitized compatibility fixture in the Phase-3 gate. Seam now / implement post-skeleton: merge budgets, post-merge audit (OQ-19), batch sweep (OQ-20) — E12 rescoped as the service tier. New Spike D (P2-E6) gates the optional Kubernetes CRD adapter (E14; dedicated ADR, number assigned when Spike D closes — cross-ref updated 2026-08-06, 0020 taken by the forge-snapshot ADR). Declined/softened: full-corpus pass as a freeze precondition, forge-artifact atomic budget ledger without proof, multi-replica duplicate-prevention guarantees, command/wording parity, in-process Go rule plugins. E10/E13 stay locked.
D-018 2026-07-21 Operator ratifications (inbox round): D-017 ratified with a wording rule — the named consumer is never named and never framed as an official predecessor/successor lineage; committed docs call it the reference use case (assent is an independent OSS generalization of the same problem shape; D-002 sharpened). Spike D (P2-E6) deferred to the Phase-3 window — CR-instance validation is not needed in the first migration wave; generic value-tree gating suffices initially. Named required obligations confirmed (ADR-0017 §2 veto window closed — anonymous vouch stays replaced). ADR-0009 amendment trade accepted: forge-native all-threads-resolved gate + SHA-pinned arming; thread resolution does not re-run assent in v1; serve (E12) is the upgrade path.
D-019 2026-07-21 Operator ratifications (inbox round, part 2): ADR-0013 CEL direction confirmed pre-spike (final acceptance still gated on Spike A evidence in P2-E5; foreclosure warning acknowledged). D-012 v1 scope reaffirmed post-D-017 (GitLab-only, one-shot modes, assert backend; rego contract-unlocked/implemented post-skeleton; E12 post-Phase-4; GitHub + remote packs locked). No AI-provenance markers in this repo (extends D-004). P1-E1-S01 partially unblocked: operator provided three private self-service repo shapes as generalization sources (pointers stay workspace-local per D-002); two more to follow.
D-020 2026-07-21 ADR acceptance round (P2-E5 — Phase-2 gate): ADR-0002 through ADR-0017 flipped from Proposed → Accepted (no full supersessions). Evidence: adr-acceptance-review.md + spikes A CEL, B e2e, C provider, secure-setup topology; forge dossiers; prior-art lessons. Partial clause supersessions noted on status lines (ADR-0017 reshapes 0003/0005/0007/0009/0010/0011/0014/0015; ADR-0016 supersedes 0012 override mechanism). OQs tagged for ADR accept closed or retagged; OQ-24 north-star timed run remains PENDING (topology accepted, <1h unconfirmed). P2-E6 still deferred (D-018). Operator ratifies after the fact (decide-and-log).
D-021 2026-07-24 Roast P1-1 fix — RulesetBinding gains require (schema/freeze). ADR-0017 §2 declares a binding's named required obligations; merge-policy prove.obligation, evaluation-input.require, and lint-hard-errors.md all referenced "a binding's require: entry", but frozen RulesetBinding had no such field. Added require per binding: optional string array, uniqueItems (stock-validator-visible, not x-uniqueKeys), absent/empty ⇒ no required obligations (vacuously covered). Closes the strongest freeze-breaker before S07 seals v1alpha1. Commit b4c2352. Revert: git revert b4c2352.
D-022 2026-07-24 Roast P1-2 fix — exact default pinned to must-contain (adopter contract). test-expectation.schema.json froze exact with a default: true annotation contradicting spec REQ-P3-E1-S05-01 + prose ("must-contain by default"). Resolved to the spec's normative behaviour: default: false; reworded so closed-list is an opt-in; added TestExactDefaultIsMustContain guard. Commit e33f3b6. Revert: git revert e33f3b6.
D-023 2026-07-24 Named-consumer-compat portability honesty — Option B chosen (P3-E1-S07 crux, D-017/B5). TestExampleContractsFixturesValidate validates fixtures with assent's own Go compiler, which asserts the vendor x-uniqueKeys keyword; a stock 2020-12 validator (ajv/check-jsonschema/python-jsonschema) ignores it, so "validates against the schemas" is only fully true for the assent compiler where collection uniqueness is concerned. Chose B (document + accept the x-uniqueKeys uniqueness divergence as a known, tracked limitation; prove the compat fixture's structured fields validate under the Go compiler and note a stock validator accepts the identical fields minus uniqueness) over A (add a non-Go stock validator to CI). Rationale: Go-only CI is the project norm (no ajv/check-jsonschema present; python3 lacks jsonschema), and x-uniqueKeys deliberately carries no $vocabulary so stock validators ignore it (compiler.go) — the compat fixture's six named-consumer signals (phase/profile/comparison-delta/ApprovalEvidence/marker/budget) are structured fields that do not depend on uniqueness, so their portability is sound by construction but unproven by test — no stock validator (ajv/python-jsonschema) runs in CI or in these tests, so the claim rests on the $vocabulary reasoning above, not on an empirical stock-validator run. Divergence is limited to collection-uniqueness on RulesetBinding/MergePolicy (both x-uniqueKeys-bearing), which the strict fixture exercises, not the compat one. Revert: drop D-023 + re-open Option A (add stock-validator CI step).
D-024 2026-07-24 Phase-3 exit gate (D-016 / ADR-0017 §8) STAGED — closes on merge to main. Both required fixtures now exist and pass the full local gate (task check) plus the S06 sweep locally (go test ./schemas/..., TestExampleContractsFixturesValidate walks examples/contracts/**): the strict end-to-end strict fixture examples/contracts/d016-strict-fixture/ (pinned target + merge-result digest, a renamed EntryRef whose content-derived identity /metadata/name stays stable across a base→head rename in a multi-entry document, exactly two independently-required obligations with two distinct proving rules, one expired typed fact, a missing required ApprovalEvidence → never APPROVE) and the sanitized named-consumer compatibility fixture examples/contracts/named-consumer-compat/ (phase/profile/comparison-delta/ApprovalEvidence/publication-marker/budget-reservation carried as structured fields, adversarially grep-proven free of prose inference). Schema-validation scope: of the six named-consumer signals only ApprovalEvidence validates against a frozen schema today; the other five (phase/profile/comparison-delta/publication-marker/budget-reservation) are proven only to exist as structured data in a sweep-skipped placeholder-apiVersion doc, pending P3-E4/P3-E5 which own their final schemas. Tests: TestD016StrictFixture, TestD016MissingApprovalNeverApproves, TestNamedConsumerCompatFixtureIsStructured. This entry records the milestone at HEAD of the unmerged lane/p3-e1-s07 (stacked on the still-unmerged base e6dbc2f); the gate CLOSES — and Phase 5 (E1–E9) engine code may begin — only once this lane and its base land on main with the schemas CI job (.github/workflows/schemas.yml) green there. Push/PR is blocked this session (D-001/D-009), so schemas CI has not yet run on these fixtures. The "no engine code before the gate fixture exists" rule (D-016) is thereby made auditable: fixtures staged and locally green, closure pending-merge.
D-025 2026-07-24 Phase-3 exit gate (D-016 / ADR-0017 §8) CLOSURE CONFIRMED. The D-024 staged condition is met: lane/p3-roast-p1-fixes (P1 base e6dbc2f) and lane/p3-e1-s07 (S07 exit-gate fixtures 93ea446) landed on main on 2026-07-24; the schemas CI job is green on main @ 93ea446 (run 30073757754) alongside verify (30073757768), so TestExampleContractsFixturesValidate validated both examples/contracts/d016-strict-fixture/ and examples/contracts/named-consumer-compat/ in CI on main, not just locally. The "no engine code before the gate fixture exists" rule (D-016) is now satisfied and auditable on main — Phase 5 (E1–E9) engine code is UNBLOCKED. (P3-E5-S02 reconciliation fixtures also landed @ 2efcf21, verify green; docs-only, path-filtered from schemas.) The D-024 schema-validation-scope caveat still stands: 5 of 6 named-consumer signals remain placeholder-shaped pending P3-E4/P3-E5. Revert: this is a factual milestone record; to reverse the gate, git revert the fixture commits (restores D-016's block on engine code).
D-026 2026-07-24 Operator bulk-ratification (via /operator-inbox). The operator ratified, after the fact, the accumulated decide-and-log rounds standing in the INBOX: D-020 ADR acceptance (ADR-0002..0017 → Accepted, accept-with-partial-supersession); OQ-24 north-star left PENDING (topology accepted, timed run unconfirmed) → retagged Phase 4; the P2-E1 Spike A (CEL) calls (CrossTypeNumericComparisons(true) + AsCELNumber/AsCELBool guards; cost budget 1000; dyn-equality silent-false hazard ⇒ require NativeTypes/typed ops for obligation proof); P2-E2 Spike B (e2e) calls (testcontainer as CI default, kind for local/demo; kind side-load via docker save --platform + kind load image-archive; SHA-guard steady-state = HTTP 409); P2-E3 Spike C (provider) calls (versioned envelopes with declaration echo; env scrubber refuses configured *TOKEN*/*SECRET* names; per-fact-type maxAge tiers); P2-E4 secure-setup topology (Premium + external-CI-project + project-access-token + all-threads-resolved + merged-results + merge trains + approval rules + .assent/** human gate as the single supported v1 topology); P1-E2 archetypes (holdout adjudication = operator task; /owner escalation to require-review; delete block/rename require-review); P1-E1 corpus (kubernetes/org + JulieOps + octoDNS; S01 samples from vision; sanitization semantics); P1-E3 forge dossier (live merge?sha= verification deferred to P2-E2 smoke); P1-E4 prior-art judgment calls; and the already-landed D-021/D-022 (schema-freeze one-line fixes @ e6dbc2f) and D-023/D-024/D-025 (P3-E1-S07 §8 exit-gate + named-consumer fixtures + D-016 closure). Residual preserved — NOT closed by this ratification: the roast's P1-3 finding stands open — x-uniqueKeys is a Go-only keyword invisible to stock JSON-schema validators (ajv / check-jsonschema / python-jsonschema), so "serialized schemas are the API" holds for structure but not for collection-uniqueness under a stock validator. D-023 chose to accept and track this divergence (Option B), which is a disposition, not a fix; ratifying the freeze must not paper over it. P1-3 remains a residual to resolve before the frozen contract is relied on externally (escape hatch = D-023 Option A: add a stock-validator CI step). Revert: this is a factual ratification record; drop D-026 to reopen the listed rounds as awaiting-approval.
D-027 2026-07-25 P1-3 residual → Option A (stock-validator CI). Operator chose A — add a non-Go Draft 2020-12 stock validator to CI — superseding D-023's Option B disposition for the open residual (D-023's freeze-time honesty note stands as history; the tracked limitation is no longer "accept forever"). Scope of A: (1) schemas CI runs a stock validator (prefer check-jsonschema or ajv) over frozen schemas/** + examples/contracts/** so structure/$ref bundling is proven outside the Go compiler; (2) uniqueness that only x-uniqueKeys can express stays Go-enforced until a follow-up migrates named collections to stock-visible constraints (uniqueItems / reshaped flats) where feasible — A does not pretend stock tools suddenly understand the vendor keyword. Backlog: open hygiene story under Phase-3 freeze residuals / pre–external-reliance (see openspec/specs/backlog.md). Revert: restore D-023-B as the standing residual and drop the stock-validator CI story.
D-028 2026-07-25 OQ-1 domain — assent.dev unavailable. Operator reports the domain is already owned by a third party. Activates the D-009 / prior freeze-B rename path: do not keep assent.dev as the public apiVersion / $id group (would point $id URLs and adopter trust at someone else's DNS). Group replacement is TBD — operator must pick before any "contracts frozen / public freeze" marketing and ideally before Phase-4 adopter-facing samples harden further. Candidates and recommendation live in INBOX + OQ-1; once chosen, one coordinated rename across apiVersion consts, schema $ids, docs, and fixtures (mechanical; high churn, low design risk). OQ-24 north-star timed run remains an open Phase-4 backlog operator task (unchanged by this entry). Revert: N/A (factual); picking a group closes the TBD.
D-029 2026-07-25 D-008 extra private shapes — defer, keep in corpus. Operator: defer delivering shapes 4–5 now; do not drop them from the corpus plan. Standing three generalized samples + OSS corpus (OQ-16) remain the committed set; additional private-shape generalization stays an optional corpus expansion tracked on P1-E1 / examples/repos/ (D-019 "two more to follow" → deferred, not cancelled). Does not block Phase 4. Revert: re-open shapes 4–5 as near-term operator tasks.
D-030 2026-07-25 Phase-3 freeze — Accept ADR-0018 + ADR-0019. Operator accepted both (was Proposed). Index rows + Status lines flipped to Accepted. Closes the P3-ADR-freeze backlog residual; Phase-4 impl may cite these ADRs as accepted contract. Revert: flip Status back to Proposed (does not un-land schemas).
D-031 2026-07-25 OQ-1 replacement path = Option A (own a domain). Operator ratified A from the D-028 options: register a domain under PlatformRelay control and use it as apiVersion/$id group (e.g. candidates assent.run, assent.tools, … — verify at registrar). Exact domain string still required before the rename lane runs. B (subdomain of existing PlatformRelay domain) and C (GitHub Pages-only) remain available if A purchase fails. Revert: reopen A/B/C pick.
D-032 2026-07-25 HostMajor negotiation = Option A (shipped). Operator ratified parse-major-from-apiVersion (provider.assent.dev/v1alpha1 → 1, HostMajor = 1; missing/unparseable → mismatch fail-closed). Rejects B (explicit protocolMajor schema field) and C (whole version string as key). Lane already on main; no code change. Revert: open a schema story for B and revert the provider major-parse tests/impl.
D-033 2026-07-26 EvaluationInput does not carry pinned SHAs; they live in DecisionRecord.pins. The frozen evaluation-input.schema.json is additionalProperties: false with no SHA field; P4-E1-S01 relocates pinned source/target/merge-result SHAs out-of-band, captured once at the CI-env edge and emitted into DecisionRecord.pins (which carries sourceSha/targetSha/mergeResultDigest). Chosen over editing the Phase-3-frozen schema (D-016) or an additive extension. Revert: unfreeze the schema and add SHA fields.
D-034 2026-07-26 P4-E1-S05 doctor protected-source signal is a documented INSECURE PLACEHOLDER; real verification deferred to E4/E5. The pure default-deny arming logic is correct, but the env reader (ASSENT_PIPELINE_CONFIG_PROTECTED) is spoofable by an author-editable CI (the exact ADR-0015 §4 self-assertion). Live GitLab forge-capability probing is out of P4-E1 scope. HARD GATE: real protected-source verification MUST replace the placeholder before any approve/merge write (S06/S08) is gated on ArmEligible. Revert/close: implement the real check in E4/E5 and delete the placeholder.
D-035 2026-07-26 P4-E1 S10 (L3 live-GitLab e2e) + S11 (D-012 real-repo adoption) parked for the operator. Infra-gated: S10 needs a live/containerized GitLab + token + testcontainer runtime; S11 needs one REAL repo on live MRs (D-012 says synthetic does not count). The autonomous loop builds S01–S09 + S12 only; these two are the operator-run Phase-4 exit-gate finishers. Revert: operator provides infra and asks the loop to attempt S10.
D-036 2026-07-26 Red-main gitleaks false positive fixed via comment reword + narrow allowlist fingerprint. main went red at 99be3c0 when gitleaks flagged a DOC COMMENT (a slash-delimited word run) in internal/change/diff.go. Because CI scans full history (fetch-depth: 0 + --log-opts=HEAD) and history-rewrite is forbidden, the fix is a .gitleaksignore allowlisting the single historic S02-commit fingerprint (one finding; does not weaken gitleaks globally) plus a comment reword for current-source hygiene. Harness note: task check does not run gitleaks/govulncheck (verify.yaml does) — run gitleaks --log-opts=HEAD locally before landing.
D-037 2026-07-26 Live forge lab + credential location for P4-E1-S10/S11. Operator designated a private gitlab.com lab project (assent-lab) as the D-012 / S11 real-repo target. Auth for agents: workspace direnv env var GITLAB_TOKEN (host gitlab.com) — token value never committed; concrete path/IDs live only in gitignored agent-context/LOCAL-INFRA.md. Does not by itself close S10 (still needs Spike-B testcontainer/kind runtime) or S11 (still needs an explicit operator ask to run assent on live MRs after S09). Revert: rotate/revoke the token in GitLab and update/remove LOCAL-INFRA.
D-038 2026-07-26 Local kind GitLab lab is authorized but deferred. Operator: do not land the long-lived hack/kind setup/status/teardown + task kind-up lab now; do record that it should be done and is allowed (Spike B / OQ-6 already picked testcontainer for CI and kind for local/demo). An early implementation was reverted the same day. When a lane picks this up: promote hack/spikes/e2e/boot-kind.sh into an idempotent lab under hack/kind/ (reuse Omnibus slim config + kind load image-archive), wire Task targets, keep CI on testcontainer. Until then the scaffold (kind-config.yaml + README) and spike harness remain. Revert: cancel the authorization or implement immediately via a dedicated lane.
D-039 2026-07-26 P4-E1 autonomous walking-skeleton slices complete on main (through aaeff79). Landed via TDD → gate → fresh independent review → ff-merge: S03 obligations aggregation; S04 DecisionRecord+PresentationModel report; S06+S08 Reconcile (post one resolvable thread / approve + SHA-pinned merge against the in-memory fake); S07 trust-boundary goldens (.assent/**→assent-policy BLOCK, SHA-guard rejection, tokenless evaluation path); S09 e2e harness wiring (compiles/vets under -tags e2e, skips without infra); S12 determinism double-run + rerun/crash-then-rerun idempotence + deterministic lowest-numeric-forge-ID duplicate-repair with additive PublicationReceipt.repairs. Two engine lanes needed 2 review rounds each: S03 (a real multi-entry non-numeric fail-open reaching APPROVE on an unproven obligation — closed by omitting unbound old/new from the CEL activation so any reference errors to REVIEW) and the forge lane (an untested atomic MergeCAS SHA-guard branch masked by the pre-check — closed by a TOCTOU racing-fake test). D-034 UNCHANGED / not tripped: S08 gates writes on injected ArmEligible test data against the fake, never the INSECURE-PLACEHOLDER env reader; real protected-source verification before a real approve/merge write remains the open E4/E5 hard gate. S12 F1 deferred to E4/E12: an all-unparseable-forge-ID canonical tie is scan-order-dependent but unreachable (real note IDs are numeric); optional one-line secondary-string-tiebreak hardening. Remaining Phase-4 exit-gate = S10+S11 (operator-run, D-035). Revert: git revert the slice commits 76d70c7 ae60727 0f0c726 4429581 57179f7 aaeff79.
D-040 2026-07-26 Provider contract-test -race exec deadline 1s→5s — no data race exists. Two independent investigations found NO data race in hack/spikes/provider; the intermittent signal: killed in TestContract was its 1s CallExec/CallHTTP deadline exceeded under -race instrumentation (the parent stdin-copy goroutine is CPU-starved, so the toyexec child blocks past 1s). Fixed by matching the already-stable isolation_test.go 5s deadline — a correct under-provisioned-harness-deadline fix, NOT race-masking (there is no race to mask). Commit a7b3579. Revert: git revert a7b3579.
D-041 2026-07-27 P4-E1-S10 walking skeleton GREEN on live gitlab.com. The real GitLab REST v4 forge adapter (internal/forge/gitlab) + assent run orchestration landed on main @ 4addc3d (independently reviewed APPROVE; all four attacks — unintended-write, SHA-guard/synthetic-digest honesty, policy-from-target-ref, token redaction — actively driven and held). Proven end-to-end against the live lab konrad.heimel/assent-lab: (M1) MR!1 partitions 6→3 → REVIEW → one resolvable thread with the ADR-0019 marker; (M2) two reruns → ZERO new threads (live rerun-idempotence, REQ-S10-02 analogue); (M3) MR!2 partitions 6→12 with --arm → APPROVE → approve + SHA-pinned merge (merged 960c6dd, pinned to evaluated source 9a816d4); unarmed APPROVE → advisory-only, zero writes. Each run emitted a schema-valid DecisionRecord with honest mergeResultDigest: null + capabilityGap (gitlab plain-merge has no merge-result digest; the CAS uses a synthetic source+target digest that never leaks into the record). --arm is a SANDBOX override — it does NOT satisfy the D-034 real-protected-source verification (S05 INSECURE-PLACEHOLDER still deferred); readPipelineDescription is never wired into the merge path. Evidence: docs/decisions/evidence/p4-e1-s11-adoption/. Revert: git revert 4addc3d. Superseded by D-134 as of 2026-08-09 — specifically the operator-facing claim "unarmed APPROVE → advisory-only, zero writes": --arm gates nothing since c05cde0, so an unarmed run on a forge-probe-eligible MR approves and merges. Everything else in this row stands, and per GUIDELINES (supersede, don't edit) the row above is byte-unchanged; this pointer is appended so a reader arriving here is not left with the stale guarantee.
D-042 2026-07-27 D-012 adoption gate SATISFIED / P4-E1-S11 done — Phase-4 exit gate CLOSED. A real gitlab.com repository (konrad.heimel/assent-lab, the D-037-designated lab — a real GitLab project with real API/merge semantics, not a synthetic testcontainer fixture) ran assent on LIVE merge requests: MR!1 (REVIEW, resolvable thread) and MR!2 (APPROVE, real SHA-pinned merge 960c6dd). DecisionRecords retained under docs/decisions/evidence/p4-e1-s11-adoption/; MRs: https://gitlab.com/konrad.heimel/assent-lab/-/merge_requests/1 and /2. With the S01–S12 autonomous slices (D-039) + S10 live green (D-041) + this adoption record, the P4-E1 walking-skeleton exit gate is closed; Phase 5 (E1–E9) is unblocked. FOLLOW-UP (S10 review F1, P2): assent run diffs only the single governed subject, so the .assent/**→assent-policy BLOCK routing is reachable only at the S07 engine tier, not yet on the live adapter path — the full-engine phase (E1) should enumerate the MR's changed-file set so the self-vouch BLOCK is exercised live (today the live self-vouch protection is policy-from-target-ref, which holds and was verified). Revert: factual milestone; to reopen, revert the S10 lane.
D-043 2026-08-03 Visual identity = Decision Gate (operator-approved direction B). The primary mark is a bold arched lowercase a / decision boundary with coral and cobalt paths converging through its center. Ship both a standalone mark (no writing) and a horizontal assent lockup, plus reproducible GitHub and Open Graph social cards and favicon/raster exports. Palette: ink #172033, cobalt #316BFF, coral #FF5C4D, warm white #F8F7F2.
D-044 2026-08-03 Documentation site = MkDocs Material on GitHub Pages. Documentation changes build with mkdocs build --strict in pull requests; pushes to main deploy the same artifact to https://platformrelay.github.io/Assent/. The README links the site and exposes only badges backed by workflows or repository metadata that exist today. Dependencies are pinned with hashes in docs/requirements-docs.txt.
D-045 2026-08-03 Security/OpenSSF hardening batch + GitHub Pages enabled (operator-requested, modeled on MKurator/Kollect). (1) Enabled GitHub Pages (Actions source) via gh api, greening the D-044 docs deploy that had failed 404 — Pages not enabled. (2) Added SECURITY.md (private vuln reporting via GitHub Security Advisories + email; posture grounded in enforced CI gates, not overclaimed) and .github/CODEOWNERS (* @konih + security-sensitive path routing). (3) Added three pinned, least-privilege workflows: codeql.yaml (SAST, go+actions matrix — closes P4-CODEQL), scorecard.yaml (OpenSSF Scorecard, weekly + publish + SARIF upload), and schedule-only vulncheck.yaml (weekly govulncheck sweep; per-push govulncheck already in verify.yaml, not duplicated). All actionlint-clean; independently reviewed before merge. Residual (operator): enable branch protection + required status checks on main (today main is unprotected, so these gates are advisory). Revert: delete the five files + gh api --method DELETE /repos/PlatformRelay/assent/pages.
D-046 2026-08-03 P5-E2-S06 — comment is a decision-NEUTRAL soft-points channel for NON-required obligations only. Implementing the ADR-0007 aggregation tail (author-declared rule.points accrue per firing; sum(points) <= binding.risk.threshold gates APPROVE as decision order #4) requires a firing that accrues points WITHOUT independently forcing REVIEW/BLOCK — otherwise the threshold can never be the deciding factor (block/challenge/require-review each already lower the decision before #4 is reached). Chose to make comment that channel: per ADR-0007's own effect table comment "Blocks merge? no". This diverges from the walking-skeleton internal/core/aggregate.Aggregate (where effectDecision(comment) == REVIEW); the new Cover path uses firingEffectDecision (comment→neutral, others→effectDecision). Fail-safe guard (ADR-0017 §2 supersedes ADR-0007's coverage line): neutrality applies ONLY to a NON-required obligation. A REQUIRED obligation (in binding.require) is satisfied-to-arm — an unproven one lowers the decision regardless of effect (comment still REVIEWs there), else a required obligation proved by a comment-onFailure rule that FIRED would fail OPEN to APPROVE on an unproven obligation. The frozen merge-policy schema permits comment on onFailure.effect, so this fail-open was live-reachable via authored policy, not latent. Points always accrue per firing regardless of the decision mapping. D-016 golden byte-identical (its required obligations use block/require-review, already lowering). Guard test: TestRequiredObligationCommentFiringNeverApproves. Revert: drop firingEffectDecision (restore effectDecision for all effects in Cover) — but then the S06 threshold never gates a firing-bearing MR.
D-047 2026-08-04 P5-E3-S01 — assent lint is a subcommand + ingests tolerantly (fail-many). (a) assent lint is a SUBCOMMAND of the single assent binary (reuses main.go dispatch + the .assent/** discovery), not a second assent-lint binary — an alias is a thin future OQ if wanted. (b) Lint does NOT reuse the strict E2 loader alone (which fail-fast-aborts on the first unknown-field/unknown-enum/missing-phase error — refusing the exact malformed packs lint exists to diagnose): it decodes each doc BEST-EFFORT into the same policy.* types AND runs the strict loader separately, capturing its refusal as ONE schema-invalid diagnostic located to the offending doc, so a malformed pack still yields ALL diagnostics in one run. This tolerant fail-many accumulator is the load-bearing architecture every later E3 check plugs into. Known limitation (documented, not a runtime fail-open): the S01 obligation-coverage check ignores phase, so an obligation proven only by a phase:off/observe rule passes the static "covered" check — at runtime cover() marks covered only at PhaseEnforce, so such an obligation is unproven → REVIEW/fail-safe; the missing-phase root cause is caught by E3-S02 no-implicit-enforce-phase. Revert: n/a (design record).
D-048 2026-08-04 P5-E3-S07 — generated rule catalogue: distinct assent catalogue subcommand + docs.url minted from the stable ID + NO fabricated deprecation. (a) Surface — the catalogue is emitted by a DISTINCT assent catalogue <dir> subcommand (JSON to stdout for the docs pipeline), NOT assent lint --catalogue: catalogue generation is not a pass/fail gate, so conflating it with assent lint's exit-code semantics muddies both (recommended default, no separate binary). (b) docs.url is GENERATED from the stable ID (<docsBase>/<pack>/<rule>, docsBase = the D-044 docs site), NOT read from an authored field: ADR-0012's authored docs: {url, summary} envelope never entered the frozen v1alpha1 merge-policy schema (additionalProperties:false), so the loader would reject an authored docs: — the catalogue is the docs-pipeline seed that MINTS the canonical anchor per rule. (c) NO deprecation/lifecycle metadata is fabricated (corrected after independent review REQUEST-CHANGES): v1alpha1's frozen contract carries no lifecycle/deprecated field on any of the four schemas, and phase: off is the ENTRY state of the off→observe→enforce rollout (ADR-0018 §1) — every newborn/pre-rollout rule — so inferring deprecated from it would be affirmatively WRONG, publishing new rules as retired. The catalogue surfaces the phase FAITHFULLY and invents no lifecycle state; deprecation metadata (REQ-E3-S07-03) is deferred to a future schema lifecycle/deprecated field (spec OQ). (d) Phase is reported at two truthful levels: phase = the AUTHORED rule phase; effectivePhase = min(rule.phase, pack-manifest ceiling) on the off<observe<enforce order — the same never-additive cap aggregate.CoverWithPhaseCeiling applies, so the catalogue does not misreport an enforce rule that a pack manifest caps to observe. Stable IDs are a deterministic function of pack+rule name (canonically sorted), never insertion order, so adding a rule EXTENDS the report without reordering/renumbering existing entries (additive-tolerant, D-017 B10). Revert: fold catalogue into assent lint --catalogue / add an authored docs+deprecated schema field and read it.
D-049 2026-08-04 P5-E3-S03 — fact-model .value convention = Option A (engine AUTO-UNWRAPS); lint enforces it + rejects non-dot facts references. Decision: an authored facts.<provider>.<name> addresses the typed fact VALUE directly; state/expiresAt/observedAt/reason/sensitive are RESERVED envelope-escape accessors at the third segment and are PERMITTED (they read envelope metadata); .value is REJECTED outright (its meaning is the auto-unwrapped value itself, so authoring it is always a mistake — that was the rejected Option-B spelling). Evidence (the corpus inconsistency the story names): every authored value reference already omits .value — the topic-registry/service-catalog packs read entry.owner in facts.author.groups, ADR-0013's canonical gallery reads new <= facts.quota.max_partitions, and a repo-wide grep finds ZERO facts.<p>.<n>.value references anywhere; the D-016 strict fixture's facts.owner.team.state is an ENVELOPE ESCAPE (reads the resolution state, not the value) and is valid under both options. Option B (.value mandatory) would invalidate the entire authored corpus + the ADR-0013 gallery; Option A matches how authors already write facts and is the only convention under which the whole corpus is simultaneously valid. Lint enforcement (E3-S03, internal/lint/facts_ref.go) — AST-based: each when/cel leaf is PARSED with cel-go's own parser (parse only, no checker/decls) and the parsed AST walked for facts identifier nodes — NOT a raw-text token scan. (A first cut hand-rolled a string-literal masker that DIVERGED from cel-go's lexer on RAW/BYTES/TRIPLE-QUOTED strings: r'\' closes at its second quote, but the masker read \' as an escaped quote, desynced, and masked away a real facts['owner'] on a controlling provider → ZERO diagnostics AND the runtime \bfacts\. scan also missed it = a PROVEN fail-open the reviewer reproduced. AST detection removes the whole class: a facts fragment inside any string form is a constant node, never an Ident, so it never false-positives and never hides a ref — no lexer to desync.) (1) facts-reference-syntax — a facts Ident that is bracket-indexed (facts['owner']/facts["owner"], an INDEX-call node), bare (size(facts), whole map), OR a dot reference the whitespace-SENSITIVE runtime scan would still miss (facts . owner, which cel-go normalizes to a Select but \bfacts\. misses) is rejected. The whitespace case is caught by a soundness CROSS-CHECK: every AST-detected dot provider must also be captured by a replica of policy.factRefRe over the raw rule text, else it is rejected. Together this makes the E2-S05 provider-posture scan SOUND BY CONSTRUCTION: after lint passes, every facts reference is a Select AND every dot provider is captured by policy.factRefRe, so a controlling provider can no longer evade the failure: open check. A leaf that fails to PARSE is a fail-safe parse-error (never assumed facts-free). (2) facts-reference-shape — a dot reference spelling .value at the third segment is rejected (drop it; Option A auto-unwraps); the other five envelope escapes and value-tree indexing past facts.<p>.<n> (e.g. facts.author.groups[0]) are permitted. REQUIRED E2 alignment (BLOCKING PREREQUISITE for the E3-S08 authored-pack evaluate gate; executed by lane C / a follow-up on internal/core, NOT changed here): aggregate/evaluate.go:factsToCEL today binds Option-B shape — the value under facts.<p>.<n>.value and the envelope fields as siblings. Under Option A it must expose the typed value AT facts.<p>.<n> (auto-unwrap for resolved facts) while keeping state/expiresAt/observedAt/reason/sensitive as reserved escapes, and docs/planning/predicate-scope.md's facts row updated to say so. Until that lands, a lane-C-rewritten pack (entry.owner in facts.author.groups) evaluates against the envelope map and fails — so the factsToCEL change MUST precede E3-S08's "archetype packs evaluate via Cover to expected decision" gate, tracked here rather than discovered at S08. Revert: drop the checkFactsReferences call in Lint() + internal/lint/facts_ref*.go; the convention record stands regardless.
D-050 2026-08-04 P5-E3-S05 — "controlling provider" for assent lint fail-open is reinterpreted from the frozen contract (the spec's literal archetypes are undiscoverable). ADR-0017/lint-hard-errors.md name three controlling-provider archetypes whose provider may not be failure: open (ADR-0017 §6). Two literal phrasings are not addressable in the frozen v1alpha1 contract: (b) "provider referenced from an Entry.identity.pointer" — identity.pointer is a JSONPointer into entry CONTENT to extract the identity key (ADR-0017 §5), never a facts.<provider> reference; (c) "approval-eligibility provider" — approval evidence is INJECTED (ApprovalEvidence), never a facts.<p> ref. The only discoverable provider surface is a facts.<provider>.<name> reference in a rule's prove.when (E3-S03/D-049 guarantees those are contiguous dot-syntax, so the scan is sound). Reinterpreted detection: (a) require-review-proof — reuse policy.ValidateProviderPosture verbatim; (b) entries-identity — the rule's prove.when reads the entry/oldEntry predicate scope (detected via a cel-go AST walk, evasion-proof per D-049) AND co-references the provider; (c) approval-eligibility — the rule proves an obligation that some RulesetBinding lists in require[] (a required authorization obligation, ADR-0017 §2/§3), even under a plain block onFailure. This is a real WIDENING beyond ValidateProviderPosture's require-review-only key (REQ-E3-S05-02 proves it: onFailure: block providers stay silent to (a) but are caught by (b)/(c)). Known lint heuristics to revisit when E5 lands the provider sensitive tier (neither is a runtime fail-open — runtime ValidateProviderPosture + the E5 provider tier are the safety authority, lint is an early advisory catch): F1 — a block-rule provider that is neither require-review nor entry-scoped nor required, with no sensitive marker in the frozen provider config, UNDER-flags at lint; F2 — an advisory (comment/block) rule that reads entry and co-references a failure:open provider OVER-flags (conservative, fail-safe direction). Revert: n/a (design record); the check lives in internal/lint/posture.go.
D-051 2026-08-04 🔴 P5-E3 — fact-model convention REVERSED to Option B (fact value at .value); SUPERSEDES D-049's Option A. Under Option B an authored fact VALUE is at facts.<provider>.<name>.value (bound only for a resolved fact — a non-resolved fact has no .value, so reading it errors → fail-safe, the E2-S05 property, UNCHANGED); value-tree navigation goes THROUGH .value (facts.<p>.<n>.value.<field> / facts.<p>.<n>.value[i]); the envelope escapes state/expiresAt/observedAt/reason/sensitive remain reserved third-segment accessors (as today, e.g. the D-016 fixture's facts.owner.team.state); every OTHER third segment, and a bare facts.<p>.<n> naming no accessor (the envelope map — an author almost always means .value), is a facts-reference-shape lint error. Why reverse (engineering grounds, advisor-endorsed): implementing D-049's Option A (auto-unwrap: bare facts.<p>.<n> = the typed value, .value rejected) requires a CUSTOM cel-go fact type in the fail-safe decision path (aggregate/evaluate.go:factsToCEL) — a fact must read as its bare value AND expose .state/… escapes, even for an EXPIRED fact with NO value — a fresh fail-OPEN surface where none exists today. Option B needs ZERO engine change: the current factsToCEL ALREADY binds the fact envelope as facts.<p>.<n> = {state, sensitive, observedAt, expiresAt?, value?(resolved only), reason?}, and S10 ALREADY reproduces the D-016 golden with it (facts.owner.team.state) — Option B is exactly that shape. D-049's decisive cost for Option A was "don't rewrite the authored corpus", but lane C is rewriting the pack corpus anyway (adding phase), so that cost is VOID. Security-critical invariant PRESERVED: the facts-reference-syntax sub-rule (the AST-based non-dot rejection — bracket facts['owner'], bare size(facts), whitespace-evading facts . owner — that makes the E2-S05 provider-posture scan sound by construction, D-049/S03's independently re-reviewed fail-open fix) is BYTE-UNCHANGED; only the facts-reference-shape sub-rule + its comments/consts flip (internal/lint/facts_ref.go, facts_ref_test.go). 🔴 operator-visible: a public policy-authoring-surface choice landing pre-release; the operator MAY commission a bare-value custom-cel-type lane (Option A) post-release if the ergonomics are preferred — this decision picks zero-engine-change safety for the v1alpha1 surface. Reverses D-049. Revert: restore the D-049 .value-rejected shape sub-rule + build the custom factsToCEL fact type.
D-052 2026-08-04 Whole-file-delete non-destructive for DOCUMENT-mode packs needs the E1-deferred fileEvents match domain — topic-registry pinned, tracked (E3-C finding). E3-C made examples/packs/{service-catalog,infra-vars} load+lint-clean, but topic-registry cannot conform: it is mode: document (one topic = one file), so whole-topic DELETION is a file-lifecycle event expressed as match.fileEvents{kinds:[delete]} — the whole-file domain E1 explicitly DEFERRED (E1 shipped entryEvents, not true fileEvents), which internal/core/policy/loader.go HARD-REJECTS at load. There is no valueChanges/files analog for a document-mode whole-file delete (contrast list-mode service-catalog, which expresses entry-removal as size(new) >= size(old)), and non-destructive is required in the binding, so the rule cannot be dropped without failing obligation-coverage. Decision: accept the gap for now — topic-registry stays pinned as a KNOWN BLOCKER by TestExamplesPacksKnownBlockers (asserts it fails to load; goes RED the moment fileEvents lands, forcing it into the conformant set). This is a real v1 capability gap (a document-mode repo's whole-file no-destruction safety obligation cannot be enforced until fileEvents is implemented) — track as a fast-follow lane (E-FILEEVENTS: add the fileEvents match domain to the loader + engine matcher) and a 🔴 pre-release project-audit remediation item. Related: N-1 (E3-C) — context-fresh's per-entry dynamic facts.oncall[entry.oncall] lookup collapsed to a single controlling fact under D-051 (dynamic fact names are a facts-reference-syntax error); documented in the rule header, a starter-corpus simplification. Revert: n/a (findings + a pinned test).
D-053 2026-08-04 🔴 P5-E6 — closing the E3-S08 full-replay deferral is a DECISION-PATH change to bindLeafActivation, landed as its own engine lane (not "adopter test harness"). E6 (assent test adopter harness) was decomposed spec-first into 9 [autonomous] stories (openspec/specs/p5-e6-adopter-test/spec.md), reusing the FROZEN schemas/testfixture/v1alpha1/test-expectation.schema.json as the strict-decode authority (no new schema) + the E1/E2 primitives (change.Diff/DiffEntries, aggregate.Cover*, buildEvaluationInput, loadCatalogueInput). The advisor flagged (and the code confirms at internal/core/aggregate/evaluate.go:71-77) that S02 — which closes the E3-S08 deferral — is NOT pure input-side: bindLeafActivation binds CEL entry: toCEL(ch.New) (the change's SCALAR new), so an entry-scoped rule + a scalar leaf rule cannot both bind over one changeset. Closing it requires extending EvalChange with an optional reconstructed Entry/OldEntry and binding entry/oldEntry to them — a change to the fail-safe decision path (TestCorePurity-scanned, first-class security surface per SECURITY.md). Decision: S02 is split into Part A (ENGINE) — additive, fail-safe-fallback binding (populated entry → bind the object; absent/ambiguous → the CURRENT scalar behaviour, so every pre-S02 evaluation is byte-identical and an unreconstructable entry never fabricates a permissive bind = fail-open) — landed + reviewed as its OWN engine lane by a fresh independent reviewer pointed explicitly at fail-safety, AHEAD of Part B (HARNESS) which reconstructs the entry tree in internal/adoptertest and populates it. S01 (input-side anchor: facts→envelope + single-rule Cover, no internal/core touch) is dispatched first and independently. Rationale: the repeated project lesson — independent review caught a fail-open on essentially every engine lane — means a decision-path change must not ride in under a test-harness label. Revert: the spec + the additive binding are self-contained; drop EvalChange.Entry + restore entry: toCEL(ch.New).
D-054 2026-08-04 P5-E6 — three harness judgment calls decided. (b) findings[].path assertion = error-as-unsupported (safe fallback), NOT an engine field-add. The frozen finding schema pins path, but aggregate.Finding has no Path field; the S03 matcher fails closed (errors "unsupported") on a path assertion rather than silently passing — a silent pass would be a silent-approve of a wrong decision. Threading path onto aggregate.Finding is a decision-path change reserved to its OWN fail-safety-reviewed engine lane (advisor 2026-08-04), NOT folded into the matcher story; fixtures avoid path until it lands (OQ). (c) Every-rule both-polarity supersedes ADR-0014's retired-vouch-scoped clause for assent test --coverage: ADR-0014 scoped the "≥1 failing case" requirement to vouch rules, but effect: vouch is retired (the frozen finding enum rejects it), so every rule now needs both a proving and a failing case (the on-disk negative/ siblings already supply the failing polarity); --coverage counts only STRUCTURED safety assertions (decision/rule/effect/score), never message~. (f) The full assent compare PolicyComparisonSuite runner (six delta kinds, five promotion gates, acceptedDeltas allowlist) is RECOMMENDED as its own epic, not E6 bloat: E6-S09 lands only the smallest honest end-to-end path (one ReplayBundle, baseline↔candidate via CoverWithProfile, one delta classified, one gate) to de-risk the reuse and name the remainder — it shares zero code with the S01/S02 base/head assembler. Revert: n/a (planning decisions; recorded in the E6 spec).
D-055 2026-08-04 P5-E6-S01 — assent test is a subcommand + reuses the frozen fixture schema + the change→EvaluationInput decoder is extracted to internal/evaldecode. (a) assent test <repo> is a SUBCOMMAND of the single assent binary (mirrors lint/catalogue dispatch in main.go + reuses loadCatalogueInput), not a second binary. (b) It strict-decodes expect.yaml against the FROZEN schemas/testfixture/v1alpha1/test-expectation.schema.json as the loader authority — authoring NO new schema; a new exported schemas.ExpectationSchema compiles the #/$defs/expectation FRAGMENT (not the top-level oneOf) so an unknown field / bad decision enum is a LOCATED field-level rejection, and the whole-schema schemas.TestExpectationSchema (embed + var) moved from testfixture_test.go into production compiler.go so the pure library can reuse it. (c) The load-bearing change→typed-EvaluationInput decoder (decodeCanonical/subjectOf/buildEvaluationInput, incl. the D-016 "6">="12" lexical fail-open fix) was EXTRACTED from cmd/assent (unimportable package main) into a new pure internal/evaldecode so BOTH the CLI shell and the pure internal/adoptertest harness reuse ONE implementation (drift would reopen the fail-open); cmd/assent keeps thin lowercase wrappers, and the regression tests moved with it. (d) The authored facts.yaml→resolved-envelope map lives in internal/adoptertest.MapFacts — an ABSENT provider/name yields NO Fact (never a fabricated resolved value that could APPROVE on an unresolved fact); this closes the fact-shape half of the E3-S08 deferral (the entry-tree half is S02, the engine lane D-053). The empty (base==head, non-opaque) changeset arm was a fail-open (vacuous APPROVE via Cover) caught by independent review and fixed to fail-safe REVIEW, mirroring run.go's `cs.Opaque
D-056 2026-08-04 🟡 P5-E6-S02 Part B — the whole-entry OBJECT is reconstructed by a NEW pure exported change.Entries, reusing change.DiffEntries' own identity keying (single source of truth, no drift). Part B (the input-side harness half; closes the E3-S08 full-replay deferral atop the D-053 Part-A engine binding) reconstructs, from a case's base/↔head/, the whole-entry object for each change's EntryRef and populates aggregate.EvalChange.Entry/OldEntry. The object MUST be keyed by the SAME identity logic DiffEntries uses or the bound entry could drift from the entry a change was tagged to. Decision (preferred option): add a small PURE exported change.Entries(file, data, cfg) (map[string]any, error) to internal/change that reuses the EXACT producer + resolvePointer + indexByIdentity/entryKeyLabel + label+":"+id ref construction DiffEntries uses — so the two never disagree on which entry a key names — rather than re-parsing raw JSON and re-deriving identity in internal/adoptertest (the drift path). It projects each entry's value subtree to a typed any via the vnode's OWN tag-discriminating cmpKey (format-neutral: JSON single-letter `n
D-057 2026-08-04 P5-E6-S09 — assent compare ships as a SEED and the full PolicyComparisonSuite runner is DEFERRED to its own epic (Judgment call (f)). The seed (cmd/assent/compare.go shell + new pure internal/compare) loads ONE immutable ReplayBundle (strict-decoded against the FROZEN schemas/decision/v1alpha1/replay-bundle.schema.json via the existing schemas.ReplayBundleSchema — no new schema, git diff schemas/ == 0), evaluates its pre-built evaluationInput under a baseline vs a candidate profile through the REUSED aggregate.CoverWithProfile (byte-unchanged internal/core), classifies the decision delta, applies ONE promotion gate, and maps the verdict to an exit code (0 pass / 1 gate-fail / 2 fail-closed-or-load-error). Scope landed = exactly two of the six taxonomy kinds + one of the five gates: newly-auto-mergeable (candidate newly APPROVEs where baseline intervened) paired with the bounded-auto-merge-widening gate (the highest-safety-value widening + the gate that owns exactly that kind), plus explanation-only (wording-only — identical decision + identical finding identities, only a leaf message differs — which by contract NEVER trips the gate). Fail-closed (REQ-E6-S09-02): any other real difference (e.g. BLOCK→REVIEW, or a same-decision finding-identity change) is a HARD ErrUnclassifiable error, never a silent gate-pass — and it exits 2, distinct from a gate FAIL (1). Reuse-boundary finding worth naming: CoverWithProfile resolves only WRITE-AUTHORITY from the precedence/profiles table — it does NOT switch the evaluated policy by profile — so the decision delta flows from the (policy, ceiling) each profile activates, supplied explicitly by the caller. What the full-suite epic still owes: the remaining four classifiers (stricter-intervention-added, destructive-or-authorization-intervention-missed, subject-or-obligation-uncovered, score-threshold-change), the other four gates, the per-delta acceptedDeltas allowlist, the ComparisonRecord emission, the multi-case PolicyComparisonSuite corpus, and profile→pack-activation resolution (so the decision itself flows from the resolved profile). Drift is guarded by tests asserting the seed's Kind constants ⊆ the frozen comparison-record deltaKind enum and the gate row is a valid frozen comparison-suite promotionGate. RECOMMENDATION (operator to confirm): make the full runner its own epic, not a fenced E6 cluster. D-057 deferred scope CLOSED (D-118 / PCS-S09): PCS-S01–S09 landed the full runner (six classifiers, five gates, acceptedDeltas, ComparisonRecord, immutable corpus, assent compare --suite, ADR-0018 exit codes); E6 seed preserved at examples/comparison/e6-seed/. Revert: remove internal/compare + cmd/assent/compare.go + the main.go dispatch arm.
D-058 2026-08-04 🟡 P5-E6-S05 — assent test --update rewrites the golden IN PLACE via yaml.v3 Node surgery (comment-preserving), NOT a re-marshal, and is REFUSED under CI (Judgment call (e)). --update refreshes a FAILING case's expect.yaml to the produced actuals so maintaining goldens is cheap — but its only justification over hand-editing is review-by-diff, which a naive yaml.Marshal(actual) defeats by clobbering the author's explanatory comments (# partitions 12 -> 16 ...). Decision: a new PURE exported adoptertest.UpdateExpectationBlock(original []byte, actual Expectation) ([]byte, error) parses the authored bytes into a yaml.Node, merges the fresh expectation mapping onto the original key-by-key (a shared key keeps the ORIGINAL key node — and thus its HeadComment/LineComment/FootComment — taking only the fresh value; a value node carries forward the original value's comments when the fresh value has none; a fresh-only key like a now-present score is appended; a key no longer in the golden is dropped), and re-encodes the WHOLE document at the fixtures' 2-space indent — so the diff is the payload change, not a whole-file reflow. It reuses S04's ActualExpectation for the model but NOT MarshalExpectation (which emits a fresh comment-free block); the S04 doc-claim that the copyable block is byte-for-byte what --update writes no longer holds once comments are preserved (expected). Fail-closed (REQ-E6-S05-01): the rewritten bytes are re-validated via LoadExpectation (frozen-schema strict decode) — a rewrite that would not re-validate is an error, never written. Preservation boundary: leading/head comments, key-level HeadComments, and value-node inline/foot comments on RETAINED keys survive (the pattern EVERY real fixture uses — a contiguous leading block attaches to the first key decision); a comment nested INSIDE a rewritten findings sequence item, or on a key dropped from the golden, is NOT preserved (the value node is replaced wholesale). No corpus fixture does this today; a future author who inlines a comment on a finding should expect it not to round-trip through --update. Overwrite safety: a PASSING case never reaches the writer (byte-identical, REQ-03); the write is deterministic (same actuals → byte-identical, REQ-04). CI guard: --update under a CI environment (os.Getenv("CI") != "") is REFUSED (exit 2, writes nothing) — auto-accepting actuals in CI would silently ratify a regression (the classic golden-update footgun). The env read + the os.WriteFile (the only I/O) live ONLY in cmd/assent/test.go; internal/adoptertest stays pure/no-FS; internal/core byte-unchanged, git diff schemas/ == 0. Revert: remove internal/adoptertest/update.go + the --update arm/flag in cmd/assent/test.go.
D-059 2026-08-04 🟡 P5-E6-S07 — assent test --coverage PROVING-SILENT attribution is resolved fail-safe by RE-DERIVING per-rule match in the harness (never from bare silence), and the both-polarity universe is scoped to ENFORCE-effective obligation rules. --coverage is a COMPLETENESS gate whose dangerous error is a FALSE "covered" (a rule shipping with only its happy path). The load-bearing ambiguity: a rule that did NOT fire in a case is silent for TWO different reasons — (a) it was exercised and SATISFIED (genuine proving polarity, ADR-0017 §2 satisfied-is-silent), or (b) it never APPLIED to the case's changes (not exercised → proves nothing). The engine aggregate.Result exposes only the FIRING rules (Findings), not "matched-and-silent", and closing that gap in the engine is forbidden here (git diff internal/core == 0). Decision (fail-safe reading): the harness ATTRIBUTES proving polarity to a rule R in a PASSING case only when R (i) is an enforce obligation rule, (ii) MATCHED ≥1 change in the case's reconstructed changeset — re-derived in internal/adoptertest.ruleMatchesAny, which MIRRORS aggregate.matchChanges (same files/values/valueChanges glob + implied kind:modify) so "did the rule apply" agrees with what the engine evaluated — AND (iii) did NOT fire. Bare silence (case (b), no match) earns NO proving credit; an unwitnessed polarity stays NOT-covered; a match-derivation defect (unsupported/absent domain — the same defect that would error the engine's own Cover) errors the gate fail-closed. FAILING polarity is attributed from a case's STRUCTURED finding pin (an ExpectFinding with a non-empty effect); a message~-only entry (empty effect) is presentation, NOT counted (REQ-E6-S07-02; --coverage counts only decision/rule/effect/score, never message~, and findings[].path is unsupported D-054 so not counted). Universe scope: only ENFORCE-effective-phase OBLIGATION (prove) rules are required to be both-polarity — an off/observe rule routes its finding to the observed bucket (never drives a decision) and the engine's own obligation-coverage marks an obligation covered ONLY from an enforce proving rule, so requiring both-polarity for a non-enforce/non-obligation rule would make the gate un-satisfiable; derived from the catalogue (EffectivePhase == enforce && Obligation != ""). Implements D-054(c)'s every-rule both-polarity (supersedes ADR-0014's retired-vouch clause). internal/core byte-unchanged, git diff schemas/ == 0; Evaluate refactored to share a pure assemble (byte-identical behaviour) so the coverage witness sees the exact same ChangeSet + Result the plain run does. Revert: remove internal/adoptertest/coverage.go + the --coverage arm/runCoverage in cmd/assent/test.go.
D-060 2026-08-04 🟡 P5-E6-S08 — the whole-pack assent test replay COMBINES a pack's multi-doc MergePolicy set and collapses a decision-neutral multi-binding RulesetBinding to its STRICTEST binding (both in cmd/assent/test.go, NOT internal/core). The exit gate surfaced two structural gaps between the S02 single-doc testdata fixtures and the SHIPPED example packs. (1) Multi-doc packs: the example packs author ONE rule file per obligation under .assent/packs/<pack>/rules/**, so a pack is SEVERAL MergePolicy docs, but aggregate.Cover takes ONE *MergePolicy; the pre-S08 Policies[0] evaluated only the first doc, leaving every other required obligation UNCOVERED → a spurious REVIEW (proving cases could never APPROVE). Decision: a new combinePolicies unions every doc's spec.rules + merges spec.entries into one whole-pack policy; because obligation coverage is "an enforce rule NAMES the obligation" (aggregate.coverage.go), the union makes every required obligation covered. Fail-closed: two docs giving the SAME entry label DIFFERENT configs is a pack defect that errors (never a silent pick that could diff a file under the wrong collection root). (2) Multi-binding routing: the packs carry a dev/prod split (two bindings differing ONLY in environment + risk.threshold); run.go's selectBinding fails closed on >1 binding because (class, environment) routing needs the Config class-matcher (unwired). Empirically the split is DECISION-NEUTRAL for the corpus (every case identical under dev and prod), so decision: a test.go-local selectBindingForTest collapses a multi-binding doc to its STRICTEST (lowest-threshold) binding — the fail-safe direction (a lower threshold can only escalate toward REVIEW, never relax) — but FAILS CLOSED if two bindings differ in class/packs/require[] (a difference that could FLIP a decision), never a guess. run.go/compare.go selectBinding is UNTOUCHED (compare.go fenced). internal/core byte-unchanged, git diff schemas/ == 0. Revert: drop combinePolicies/selectBindingForTest/sameStrings and restore Policies[0] + selectBinding in cmd/assent/test.go. SUPERSESSION PENDING (D-142, 2026-08-10): selectBindingForTest's strictest-collapse is scheduled for DELETION by DEM-S00, which wires the real (class, environment) routing this row worked around. It is deleted rather than kept as a fallback because two live selection paths is how a fail-closed guarantee is lost quietly — a future routing bug would silently degrade to the collapse instead of refusing. combinePolicies is NOT affected and stays. Note this row's own evidence is what made the gap invisible: the corpus split was decision-neutral, so no shipped example ever exercised a decision-flipping (class, environment) split.
D-061 2026-08-04 P5-E6-S08 EXIT GATE — corpus reconciliation, the service-catalog fixture-defect rebuild, and the topic-registry disposition (Judgment call (d)). (a) Discovery root: the gate runs the ADOPTER .assent/tests/** format (expect.yaml, schema-conformant) as its discovery root; the examples/archetypes/** expected.yaml seed manifest stays the P3-E3 golden seed (the known expected.yaml vs expect.yaml filename split), CROSS-CHECKED by TestExampleCorpusReconcilesArchetypeManifest — each mapped obligation's proving+negative decision must equal archetype-goldens.md v1. (b) findings[].path stripped from all 22 example-pack expect.yaml (the S03 matcher fails closed on path, D-054); rule+effect+obligation retained. (c) service-catalog was authored for E3 STATIC lint and never evaluated — whole-pack replay surfaced real fixture defects, fixed truthfully (NOT weakened, NOT an engine edit): nested lists (endpoints/tags) make the modify-only differ (E1-S05) go OPAQUE → stripped to all-scalar entries; allowed-fields' when:false deny predicate can never earn --coverage proving polarity (it fires on every match) → re-authored as a PROVABLE allow-list (path.endsWith("/oncall"), matching the archetype intent); non-destructive matched /services (a whole-list modify entries-mode never emits) → re-authored to entry-level add/delete with kind != "delete"; the context-fresh facts key orders-rotation (a hyphen is not CEL-dot-accessible) → orders_rotation; all 10 cases re-authored so each proving case satisfies all five required obligations and each negative drives exactly its rule. infra-vars needed only the absolute-glob valueChanges pointer fix (/workloads/*/memory_mb) + authored min/max-replicas both-polarity cases. (d) topic-registry EXCLUDED from the green corpus + --coverage: it is mode: document and its non-destructive rule needs the E1-DEFERRED fileEvents domain, so the strict loader HARD-REJECTS it (D-052) — it stays the PINNED known-blocker (TestExamplesPacksKnownBlockers), the tracked corpus gap, NOT deleted. (e) Logged divergence: service-catalog's non-destructive is ENTRY-removal → require-review (REVIEW), whereas the manifest's no-destruction is FILE-level deletion → block (BLOCK) — a different, stricter obligation living in the deferred fileEvents domain; asserted in the reconciliation test so it can never rot silently. A deliberately-broken pack under cmd/assent/testdata/broken-pack proves the failure path (S04 diff UX + exit 1); go test ./... (+ a dogfood-examples Taskfile target and a verify.yaml step running the real assent test CLI) is the CI dogfood job. internal/core byte-unchanged, git diff schemas/ == 0, cmd/assent/compare.go/internal/compare untouched. Revert: remove cmd/assent/test_corpus_test.go + testdata/broken-pack + restore the pre-S08 service-catalog fixtures/rules.
D-062 2026-08-04 P5-E-FILEEVENTS (EFE) decomposed spec-first; loader narrows fileEvents kinds to {add,delete} (fail-closed) + NO frozen-schema change. The match.fileEvents contract is FROZEN (policy.FileEventsMatch, the fileEventsMatch schema $def) but UNIMPLEMENTED — three hard-rejects (loader.go:32, aggregate/coverage.go:451, adoptertest/coverage.go:301). EFE (5 stories, openspec/specs/p5-e-fileevents/spec.md) implements it to close D-052 (topic-registry unpin) + D-061 (service-catalog file-delete→BLOCK). Key ground truth (de-risks it): the FROZEN evaluation-input.schema.json ALREADY models a whole-file event (change.path == "", kind: delete, entryRef file:<path>) — a file-event EvalChange{Path:"", Kind:"delete", Subject:"file:x"} validates with NO schema change (git diff schemas/ == 0 is an epic DoD). The path=="" bit is the discriminator between a whole-file event and a value-level add/delete. Judgment call (b) DECIDED (advisor-affirmed): the loader accepts kinds ⊆ {add,delete} and REJECTS modify+rename at load — a fail-CLOSED v1 narrowing beyond the frozen enum (the safe direction, matching the loader's existing narrow-by-rejection pattern) that closes a real vacuous-cover fail-open: coverage.go marks a required obligation covered when an enforce rule merely NAMES it, so a required obligation proven only by an un-emittable kind (rename/modify, never minted as a path=="" event) would be silently APPROVE. Judgment call (d): a single pure change.FileEvent constructor is the sole path=="" minter (minter/matcher/mirror must agree — the evaldecode "one canonical decoder or the fail-open reopens" lesson). S01 (loader+matcher, ENGINE) lands + engine-grade-reviewed AHEAD of S02 (minting) — the D-053 Part-A/Part-B precedent. Revert: restore the three hard-rejects.
D-063 2026-08-04 ✅ OPERATOR-CONFIRMED (2026-08-04): REVIEW. (EFE Judgment call (a)) — the default decision for an UNMATCHED whole-file DELETE. The operator confirmed the fail-safe REVIEW default via AskUserQuestion; no relaxation to APPROVE authorized. Original framing follows.** The naive decomposition claimed emitting a clean file-event and preserving blanket-REVIEW for the ungoverned delete are mutually exclusive — the advisor (2026-08-04) showed that is a FALSE dichotomy: they are SEPARABLE (the clean event lets fileEvents rules match; the engine can STILL fail-safe on a delete no rule covers). TODAY every whole-file delete is opaque → REVIEW. If an unmatched file-delete were allowed to become APPROVE, the concrete regression is a file whose CONTENTS are governed by values/valueChanges rules (which by S01's path!="" disjointness never match a path=="" delete) getting ZERO protection when deleted wholesale — silently flipping REVIEW→APPROVE on upgrade. That is a fail-OPEN on a destructive operation in a fail-safe-first project — a product/behavior-contract choice, NOT decide-and-log (the code doesn't decide it; product intent does, and the tidy "consistent-with-unmatched-changes" reading cuts toward the LESS-safe default). Decision: S02 ships the fail-safe default — an unmatched whole-file DELETE → REVIEW (additive + walk-back-able: an operator can later relax to APPROVE with no adopter ever under-protected; the reverse under-protects now and tightening later is a breaking change in the annoying direction). 🔴 Operator: confirm REVIEW, or authorize APPROVE-for-ungoverned-delete (a follow-up would relax the default). S02 must NOT let emitting a clean event silently flip REVIEW→APPROVE. Revert: n/a (default is REVIEW; the relaxation is the reversible follow-up).
D-064 2026-08-04 EFE-S02 — the unmatched-whole-file-DELETE fail-safe escalation MECHANISM (implements D-063's REVIEW default). D-063 decided the policy (an unmatched whole-file delete → REVIEW, never APPROVE); this logs the mechanism. Decision: a minimal, additive escalation in the decision path (internal/core/aggregate/coverage.go, in cover, after the uncovered-obligation guard): for each change that is a WHOLE-FILE delete event (path=="" AND kind=="delete"), if NO enforce-effective fileEvents rule governs it, worse(decision, REVIEW) and emit a synthetic aggregate.unmatchedDelete finding (effect require-review, code fileEvent.unmatchedDelete, subject the change's file:<path>). "Governed" mirrors covered[]'s enforce-only gate (fileDeleteGoverned): a prove rule with a match.fileEvents domain whose effective phase (after the pack ceiling) is enforce (NOT observe — observe findings are decision-excluded; treating observe as governing would suppress escalation → APPROVE, a D-063 fail-open caught on EFE-S02 review), and whose matchChanges selects the delete. An OFF/disabled rule, a non-fileEvents domain (by S01 path!="" disjointness it can never select a path=="" event anyway), and a direct-effect rule the loop skips all count as NOT governing → the delete escalates (the fail-safe direction). Why this shape: it is a no-op on every changeset the differ mints today (all path!="") so every existing evaluation is byte-identical; it can only ever RAISE toward REVIEW (never relax), so it is walk-back-able exactly as D-063 requires; and a GOVERNED delete (a fileEvents rule actually selects it — proven clean-true → APPROVE, or failing → its own effect) is NOT double-lowered (pinned by the positive-discriminator case in TestUnmatchedFileDeleteFailsSafeReview). An unmatched ADD does NOT escalate (adding isn't destructive, D-063). Authority: D-063. Revert: delete the escalation loop + fileDeleteGoverned + ruleUnmatchedDelete/kindDelete consts in internal/core/aggregate.
D-065 2026-08-04 🟡 DECIDED (awaiting approval) — E5 Judgment call (a): digest-pin + provider declarations land via an INTERNAL host declaration type first; git diff schemas/policy/ stays 0 through E5-S01..S03. Spike C's richer Config (digest, projections, capabilities) is ahead of the thin frozen policy.Provider{Type,URL,Failure} (config.schema.json). Silently widening the frozen Config schema to unblock the host would couple E5 to a contract change and reopen the freeze. Chose: keep the public Config surface as-is; the host loads digest pins / projections from an internal declaration (or a host-owned doc) until a deliberate schema story is justified by an adopter-facing knob. A follow-up schema lane can promote fields later. Authority: ADR-0015 §7 (digest-pin required) + P3 freeze. Revert: if operator wants Config widened now, open a schema lane before S03 digest-pin verify.
D-066 2026-08-04 ✅ CLOSED D-052 (EFE-S04). topic-registry non-destructive re-authored to the PROVABLE fileEvents form: match.fileEvents{paths:["topics/**/*.yaml"], kinds:[add,delete]}, prove.when: 'kind != "delete"' (not when:"false"). Inline cases.yaml file-ADD proving (APPROVE/silent) + file-DELETE failing (REVIEW) evaluate green under assent test both polarities; topic-registry joins the load+lint+evaluate green corpus (greenExamplePacks, TestExamplesPacksLoadAndLintClean); the D-052 known-blocker pin is fully retired. Pack shape note: entries switched from mode: document to map-at-root (root: "", identity = topic-name key) because document mode leaves Entry nil under whole-pack replay (D-056 change.Entries returns empty for document mode), so entry.owner cannot bind — map mode reconstructs the topic object without an engine change. Bounded-change pointer widened to /*/partitions. P2 (logged, not blocking): a pure file-ADD proving case leaves ownership/schema-valid unrun because those rules match value-level domains (path!="") disjoint from fileEvents (path=="") — a product gap for new-topic content governance on create; track as a follow-up, not an EFE-S04 blocker. Renumbered from a colliding D-065 after main claimed D-065 for E5 Judgment call (a). git diff schemas/ == 0. Revert: restore when:"false" + kinds:[delete] + document mode + corpus exclusion.
D-067 2026-08-04 ✅ CLOSED D-061 (EFE-S04). service-catalog now expresses BOTH non-destructive shapes, separately asserted: (1) entry-removal → REVIEW (no-entry-removal valueChanges require-review — unchanged); (2) whole-file catalog delete → BLOCK (no-catalog-file-deletion fileEvents, onFailure effect block) matching archetype-goldens no-destruction/delete. TestExampleCorpusReconcilesArchetypeManifest asserts both so the former REVIEW-vs-BLOCK divergence cannot rot silently. Inline catalog/cases.yaml carries file-ADD proving + file-DELETE failing polarities for the new rule. D-052 and D-061 remain SEPARATE closures. git diff schemas/ == 0. Revert: drop file-non-destructive.yaml + catalog cases + restore the divergence-only assertion.
D-068 2026-08-04 ✅ CLOSED E3-S05 INBOX F1/F2 (E5-S04). Sensitive tier is host-enforced: declaration.sensitive:true → MaxAgeSensitive 15m ceiling at load (ValidateDeclarationMaxAge — exceed rejected, never clamped; shared with E5-S02). Resolved facts bind via provider.ToAggregateFact, which sets aggregate.Fact.Sensitive from the declaration for the CEL envelope. E8 redaction handoff: host propagates the marker only; forge presentation/renderer redacts sensitive values (ADR-0012 A-08 / ADR-0016) — no renderer work in E5. Documented in docs/planning/provider-contract.md § Consequences #4. git diff schemas/ == 0. Revert: drop ToAggregateFact + S04 tests; restore contract note.
D-069 2026-08-04 P5-E5-S08 — thin dedicated builtin/resource-owner over repo-file for REF-GAP-1 (Judgment call in-lane). v1 could have reused repo-file walk-up for an in-repo ownership map, but a dedicated builtin gives explicit fail-closed semantics: absent registry key → ErrResourceOwnerUnknown → StateUnavailable (never resolved ""), distinct from repo-file's "file absent" vs "key absent" split and clearer for C7 referenced-resource lookups. Hermetic L0 uses FakeResourceOwner + testdata/resource-owner/owners.yaml via LoadResourceOwnerMap; HTTP registry deferred. Query/declaration subject uses frozen-schema entry (not a new resource kind). Type string: builtin/resource-owner; output fact: owner; maxAge 24h (registry lookup tier). git diff schemas/ == 0. Revert: drop resource_owner*.go + registry helper + testdata.
D-070 2026-08-04 P5-E5-S09 — SKIP ownership-file builtin; documented deferral (REQ-E5-S09-01). E5-S07 builtin/repo-file + E5-S08 builtin/resource-owner (D-069) already cover in-repo ownership lookup for REF-GAP-1/C7; the spec's skip-if-covered path applies. CODEOWNERS-style ownership_file builtin, HTTP-generic polish, and OIDC/Keycloak/LDAP remain deferred until a named consumer. Do NOT ship ownership_file.go. git diff schemas/ == 0. Revert: open a lane to ship builtin/ownership-file when a consumer appears.
D-071 2026-08-04 P5-E5-S10 — DEFER REF-EX C5–C7 archetype seeding (REQ-E5-S10-03 Judgment call c). Blocker: honest C5–C7 fixtures need dedicated pack policy rules + archetype-goldens.md manifest rows that exercise provider-shaped fact keys (facts.quota.*, placement allow-list, referenced-resource ACL) — beyond exit-gate wiring scope. Existing bounded-change/ownership archetypes already stub equivalent obligations via facts.yaml; live-path proof is TestE5ExitGateResolvedFacts (repo-file resolved quota). Seed C5–C7 in a follow-on REF-EX authoring lane once pack rules exist. git diff schemas/ == 0. Revert: open REF-EX lane and add examples/archetypes/{quota-ceiling,placement-allow-list,referenced-resource-ownership}/.
D-072 2026-08-04 P5-E5-S10 — E5 exit gate CLOSED. Isolation + state goldens run under task check (go test ./...); controlling failure:open posture tests green (TestFailOpen*); hermetic run path resolves builtin/repo-file facts (TestE5ExitGateResolvedFacts); REF-GAP-1/2 marked closed in backlog; git diff schemas/provider/ == 0. Builtins wired at cmd/assent edge via host declaration repoFile/resourceOwner fields (D-065 internal declaration — no schema widen). Revert: drop S10 wiring + exit-gate test; reopen REF-GAP rows.
D-073 2026-08-04 CLOSED (E8-S13) — summary-comment slot (P3-E5 step 3) implemented. E8-S12 extended Forge with ListBotNotes/UpsertComment + Reconcile preamble; E8-S13 wires render.RenderSummary into buildDesired and P3-E5 fixture replay with rendered bodies. Hermetic fake + conformance green; live GitLab note upsert remains optional (E4-S11 class). Authority: E8 spec S12+S13, D-093.
D-074 2026-08-04 P5-E4 — D-034 replacement: forge-probe-primary when PAT present. When assent run/doctor has a GitLab token, protected-source and merge-gate signals come from Snapshot (project settings / CI config path detection), not spoofable env vars (ASSENT_PIPELINE_CONFIG_PROTECTED). Env reader stays for forge-less doctor diagnostics only and must carry an INSECURE banner; it must never alone arm a real merge when forge probe data exists. Authority: E4 spec judgment call (b); closes D-034 on the forge-backed path in E4-S05/S06. Revert: restore env-only arming.
D-075 2026-08-04 P5-E4 — Free-tier GitLab: approval evidence unsatisfiable + auto-merge blocked. Without Premium approval-rules APIs (forge dossier C6/C7), Resolve returns an explicit capability gap — never fabricated ApprovalEvidence — and doctor reports AutoMergeEligible=false for archetypes needing require-review proof. Authority: E4 spec judgment call (c); OQ-23 / ADR-0017 §3. Revert: n/a (tier honesty).
D-076 2026-08-04 P5-E4 — Snapshot changed-file source = MR diffs API. GET .../merge_requests/:iid/changes (new_path/old_path) is the v1 enumeration path for forge Snapshot; compare API deferred unless a future lane proves diffs insufficient for self-vouch detection. Authority: E4 spec judgment call (d). Revert: open compare-API lane if diffs misses rename/delete shapes.
D-077 2026-08-04 P5-E4 — changed-file composition: --checkout precedence over Snapshot. E1-S08 already ships --checkout + foldCheckout local-tree enumeration. When --checkout is set, the local checkout is the sole classifier changed-file authority (ADR-0008 §4 presence/content); Snapshot path-list is ignored for enumeration (Snapshot still used for MR heads, Resolve, arming probe). When --checkout is unset and a forge token is present, Snapshot changed-files apply (closes D-042 F1 on live path). Never union the two lists — conflicting sources fail-safe to under-coverage. Authority: E4 spec judgment call (e); REQ-E4-S06-02/07. Revert: explicit opt-in union flag (would need new judgment call).
D-078 2026-08-04 P5-E4 — D-034 run-path closure requires S06 forge-probed arming, not S05 alone. S05 (assent doctor) reporting forge-probed ArmEligible is necessary but insufficient: S06 MUST wire the same probe into assent run so approve/merge writes gate on forge-probed preconditions when a PAT is present — --arm sandbox and readPipelineDescription() env vars must not arm real writes (REQ-E4-S06-05/06). Revert: restore doctor-only closure claim.
D-079 2026-08-04 P5-E4-S10 — E4 autonomous exit gate CLOSED. task check green (incl. D-010 coverage ≥90% after moving E4-S09 conformance httptest harness from production reconciliation.go into *_test.go); all internal/forge/** + cmd/assent/*forge* tests run under verify; conformance package has zero skipped autonomous cases; hermetic run path proves Snapshot→Resolve→Reconcile with require-review APPROVE + forge-probed arming (TestE4ExitGateHermeticForgePath). D-034 CLOSED (forge-backed path): S05 doctor probe + S06 run-path arming replace env placeholder when PAT present (D-074/D-078). D-042 F1 CLOSED (adapter path): S08 self-vouch BLOCK on Snapshot changed-files (D-077). S11 live GitLab L3 re-run remains optional infra-gated. git diff schemas/ == 0. Revert: drop exit-gate test + harness move; reopen D-034 placeholder on forge path.
D-080 2026-08-04 P5-E7 — E7 extends E4 conformance; does not fork a second suite. The forge-neutral catalog indexes E4 REQ-IDs (internal/forge/conformance SHA-guard, P3-E5 replay, spoof filter; E4-S08 self-vouch in cmd/assent) and adds E7-only hermetic cases. Implementation stays in internal/forge/conformance + thin cmd/assent trust-boundary tests — no parallel test/conformance/ tree. Authority: E7 spec judgment call (a). Revert: delete catalog + duplicate suite.
D-081 2026-08-04 P5-E7 — Autonomous exit (S08) does not require live L3. S07 (and absorbed E4-S11) skip without ASSENT_E2E_GITLAB/token; operator-attested green run is evidence in docs/decisions/evidence/, not a merge blocker — same pattern as E4-S10/S11. Authority: E7 spec judgment call (b). Revert: make live L3 mandatory (would block autonomous lanes).
D-082 2026-08-04 P5-E7 — Sample-repo generator reads only committed examples/repos/ trees. hack/e2e/seed-sample-repo.sh archives local shape content; no network clone, no private-shape fetch (D-002). Live push requires --endpoint + token (S07/S06 infra). Authority: E7 spec judgment call (c). Revert: add remote-clone mode (needs new judgment call + sanitization review).
D-083 2026-08-04 P5-E7 — kind lab (S06) optional; testcontainer remains CI default. Spike B / D-026 unchanged — S06 promotes D-038 hack/kind/ scaffold when an operator wants durable local demo; not on the autonomous critical path. Authority: E7 spec judgment call (d). Revert: flip CI default to kind (needs Spike re-measurement).
D-084 2026-08-04 P5-E7 — Conformance catalog is forge-neutral; GitHub cases stubbed github-deferred. Each catalog row records gitlab | both | github-deferred; live GitHub proof waits for E10 unlock (D-012). Keeps ADR-0005 honest without shipping GitHub adapter. Authority: E7 spec judgment call (e). Revert: drop github-deferred rows when E10 unlocks.
D-085 2026-08-04 P5-E7 — Catalog path: internal/forge/conformance/catalog.yaml. Co-located with the executable conformance suite (ADR-0005); not docs/contracts/forge-conformance/ (frozen fixture prose lives under docs/contracts/). REQ-E7-S03-02 catalog-indexes E4-S05-05 there — no duplicate TestDoctorUnprotectedPipelineBlocksArming. Authority: E7 spec judgment call (f). Revert: move catalog to docs tree (needs path update across S03/S08).
D-086 2026-08-04 P5-E7 — .github/workflows/verify.yaml is the authoritative CI superset; task check unchanged. verify owns determinism (S04), sanitization (S05), e2e-vet, gitleaks, race+coverage; task check stays local fmt/vet/lint/test/coverage/build and is not extended with verify-only steps. PR merge requires verify green. Authority: E7 spec judgment call (g). Revert: fold verify steps into task check (widens local gate).
D-087 2026-08-04 P5-E7-S08 — E7 autonomous exit gate CLOSED. verify.yaml enforces S04 determinism + S05 sanitization + e2e-vet (D-086); forge-neutral catalog at internal/forge/conformance/catalog.yaml indexes all E4 + E7 L1 cases with green tests (TestE7ExitGate* + spec -run filter); backlog/later-phases mark autonomous slice closed. D-080–D086 cited; S06/S07 optional per D-081/D-083 (kind lab + live L3 skip without infra). git diff schemas/ == 0. Revert: drop exit-gate tests; reopen E7 backlog status.
D-088 2026-08-04 P5-E8 — tier-0 presentation block extends config.schema.json. ADR-0016 tier 0 requires repo-level knobs in .assent/config.yaml; strict Config additionalProperties:false rejects an unschema'd block. E8-S02 adds optional nested presentation (verbosity, emoji, collapseThreshold, locale, per-env overrides) — the one allowed schema touch for E8; PresentationModel schema unchanged. Authority: E8 spec judgment call (a). Revert: remove block + load defaults only in code.
D-089 2026-08-04 P5-E8 — presentation defaults: global verbosity: standard, emoji: true, collapseThreshold: 5, locale: en; per-environment overrides optional under presentation.environments[] keyed by Config environment name. Authority: E8 spec judgment call (b). Revert: change defaults in internal/render/options.go only.
D-090 2026-08-04 P5-E8 — sensitive fact display uses literal [redacted]. Render-time only (D-068 handoff): CEL evaluation retains raw values; forge-facing markdown replaces sensitive fact values and sensitive debug lines with [redacted]. Authority: E8 spec judgment call (c). Revert: switch placeholder or drop redaction (security regression).
D-091 2026-08-04 P5-E8 — comment field clamp: 500 runes per interpolated/scalar field in forge markdown (ellipsis suffix); full values remain in JSON report / ReplayBundle. Authority: E8 spec judgment call (d). Revert: raise limit in render clamp helper.
D-092 2026-08-04 P5-E8 — render fixture layout: examples/render/<case>/. Committed presentation-model.json, optional render-context.json (CEL activation + marker pins + config), golden expect.<artifact>.md. Authority: E8 spec judgment call (e). Revert: move corpus under internal/render/testdata/.
D-093 2026-08-04 P5-E8 — D-073 closes in E8-S12+S13 (split). S12 extends Forge with ListBotNotes/UpsertComment, DesiredReviewState.Summary, and additive Reconcile step-3 preamble; S13 wires renderer summary bodies + P3-E5 fixture replay. Hermetic fake first; live GitLab note upsert optional (E4-S11 class), not an E8 merge blocker. Authority: E8 spec judgment call (f). Revert: reopen deferral in INBOX.
D-094 2026-08-04 P5-E8 — marker bytes owned by internal/render envelope. render.FormatMarker / render.Envelope is the single writer of <!-- assent:marker … -->; internal/forge/gitlab delegates assembly (parse stays in forge/gitlab). Authority: E8 spec judgment call (g). Revert: restore gitlab-local marker JSON assembly.
D-095 2026-08-04 P5-E8 — presentation lint at pack/config load (assent lint), not render time. E8-S11 extends E3-S04 for docs.summary/debug: + tier-1 template rejection only — does not re-prove assert scope. Unknown CEL fields are hard errors, never <no value> at render (ADR-0016 §2). Authority: E8 spec judgment call (h). Revert: move checks to render-only (weakens load-time gate).
D-096 2026-08-04 P5-E8 — render.Context is ephemeral (Options + Activation + pack metadata). Not a frozen public contract or JSON schema — only PresentationModel is pinned (ADR-0016 §3). Authority: E8 spec judgment call (i). Revert: introduce parallel render-context schema (violates D-088 fence).
D-097 2026-08-04 P5-E8 — CLI primary flag is --finding per ADR-0016 §4. Names the render fixture directory under examples/render/<case>/; --fixture is an undocumented alias for scripts/tests only. Authority: E8 spec judgment call (j). Revert: rename to --fixture only (needs ADR-0016 amendment note).
D-098 2026-08-04 P5-E8-S14 — E8 autonomous exit gate CLOSED. task check + task determinism green; verify.yaml wires render goldens (TestRenderGoldens, TestRenderSummaryGolden) at -count=2 (E7-S04 pattern); exit-gate tests pin render+lint+summary reconcile slice (TestE8ExitGate*, forge ReconcileSummary/UpsertComment); TestE8ExitGateSafetySplit proves ADR-0014 wording drift does not fail structured assent test assertions on service-catalog; backlog/later-phases mark E8 AUTONOMOUS COMPLETE. D-073 closed (S13); D-088–D-097 cited. Schema drift limited to D-088 presentation block (schemadrift helper). Revert: drop exit-gate tests + determinism wiring; reopen E8 backlog status.
D-099 2026-08-04 P5-E9 — link-time version via -X main.version={{.Version}}. Dev/default builds keep 0.0.0-dev in cmd/assent/main.go; goreleaser and Taskfile.yml inject semver from tag or ASSENT_VERSION. Authority: E9 spec judgment call (a). Revert: remove ldflags wiring.
D-100 2026-08-04 P5-E9 — goreleaser v2 cross-compile targets. linux/darwin/windows × amd64/arm64, skip windows/arm64; CGO_ENABLED=0, -trimpath, -s -w ldflags. Authority: E9 spec judgment call (b). Revert: narrow targets in .goreleaser.yaml.
D-101 2026-08-04 P5-E9 — git-cliff changelog omits commit SHAs (oss-playbook #4). Release notes and CHANGELOG.md use categorized subject lines only — no squash-invalidating SHAs. Authority: E9 spec judgment call (c). Revert: enable SHA links in cliff.toml.
D-102 2026-08-04 P5-E9 — CI hardening S04 is audit-first; do not duplicate D-045. CodeQL, Scorecard, govulncheck (push + weekly), gitleaks, SHA-pinned actions already shipped; E9 closes residual gaps only (actionlint workflow, README Scorecard badge, status doc). Authority: E9 spec judgment call (d). Revert: add duplicate security workflows.
D-103 2026-08-04 P5-E9 — MkDocs published nav = product pages only. Home, Vision, ADRs, Architecture, Usage, API stability, Decisions; exclude docs/planning/** and openspec/** from nav (files stay in-repo). Authority: E9 spec judgment call (e); oss-playbook #3. Revert: restore Planning subtree in mkdocs.yml.
D-104 2026-08-04 P5-E9 — README maturity table post-E8. Honest Core/Beta/Planned rows (GitLab/assert/provider/renderer Core; GitHub Planned; Rego Locked; serve Designed); status moves to alpha GitLab CI path. Authority: E9 spec judgment call (f). Revert: restore pre-alpha-only callout.
D-105 2026-08-04 P5-E9 — OQ-2 GitLab dogfood mirror DEFERRED. GitHub (PlatformRelay/assent) remains canonical; read-only GitLab mirror is optional operator infra, not an E9 merge blocker. Authority: E9 spec judgment call (g). Revert: operator picks dual-primary in INBOX.
D-106 2026-08-04 P5-E9 — VHS demo tapes committed; GIF publish optional. .tape sources under docs/assets/demos/; README embeds GIFs only after assets exist (oss-playbook #1). Authority: E9 spec judgment call (h). Revert: remove tapes.
D-107 2026-08-04 P5-E9 — Homebrew via goreleaser brews → PlatformRelay/homebrew-tap. In-repo formula template for review; tap push infra-gated. Authority: E9 spec judgment call (i). Revert: curl-only install channel.
D-108 2026-08-04 P5-E9 — first public release tag v0.1.0. First installable alpha reflecting E2–E8 product surface. Authority: E9 spec judgment call (j). Revert: pick different semver at tag time.
D-109 2026-08-04 P5-E9 — release supply-chain: goreleaser cosign + SBOM + actions/attest SLSA on archives/checksums (mkurator pattern). Authority: E9 spec judgment call (k). Revert: unsigned releases only.
D-110 2026-08-04 P5-E9 — curl install script verifies SHA256 checksums before extract (fail-closed on mismatch); cosign verify when signature bundles present, skipped when absent (snapshot/autonomous path); --require-signature fails closed post-S06. Authority: E9 spec judgment call (l). Revert: require cosign always (blocks autonomous close).
D-111 2026-08-05 P5-E9-S13 — E9 exit gate CLOSED. Autonomous half: hack/release/exitgate_test.sh + S01–S04 + S07a + S08→S09 + S10 + S11 + S12 green; D-099–D-110 cited. Infra half proven @ tag v0.1.0 (49ba1ad): release workflow 30982306282 green; S05–S06 signed assets (archives, checksums, SPDX, sigstore, SLSA provenance); curl install + assent version → 0.1.0; cosign verify OK with GitHub OIDC identity. Homebrew Formula published to PlatformRelay/homebrew-tap Formula/assent.rb via release rebuild 31004620702 after 3423067 (drop tagged --skip=publish); live brew install assent → 0.1.0. Optional residual: rotate HOMEBREW_TAP_GITHUB_TOKEN from classic PAT to fine-grained (Contents: write on homebrew-tap only). Revert: delete Formula commit on tap / reopen brew docs as pending.
D-112 2026-08-04 P5-PCS-S01 — Option A: extract shared catalogue helpers to internal/catalogue (LoadFromDir, CombinePolicies, MergePolicyForProfile). Move loadCatalogueInput + combinePolicies out of package main into importable internal/catalogue APIs; add pure MergePolicyForProfile(profile, Input) over an already-loaded catalogue (no FS inside the helper). cmd/assent/{test,catalogue,compare}.go import the package; internal/compare does not. Compare CLI resolves baseline/candidate PolicyProfile activations via LoadFromDir + MergePolicyForProfile (legacy MergePolicy seed fixtures retained until PCS-S07). Revert: inline helpers back into cmd/assent and drop profile activation wiring.
D-113 2026-08-04 P5-PCS — Corpus root examples/comparison/<suite>/. Each suite directory holds suite.yaml (PolicyComparisonSuite), cases/<caseId>/bundle.json (+ optional replayBundleRef paths), and committed records/ golden ComparisonRecords for regression. Immutable corpus rule: fixed caseId → fixed replayBundleDigest; revise by minting new caseId. Single-dir seed layout (baseline.yaml/candidate.yaml) remains for one-case dev fixtures only. Authority: PCS spec judgment (b). Revert: relocate corpus or drop suite.yaml layout.
D-114 2026-08-04 P5-PCS — Replay bundle digest = canonical JSON SHA-256. Digest over bundle bytes (ADR-0017 §9 canonicalization discipline; internal/core/hash). Mismatch vs suite replayBundleDigest fails closed before evaluation. Authority: PCS spec judgment (c). Revert: drop digest pre-check (allows stale corpus).
D-115 2026-08-05 P5-PCS-S07 — ADR-0018 exit codes for assent compare (0 all-pass; 1–5 first failing gate in table order; 6 fail-closed/load/digest/classification). Full suite mode (--suite) and single-dir seed both use the same mapping; E6-S09 seed widening fail migrates 1→4, unclassified/load 2→6. Revert: restore seed 0/1/2 mapping.
D-116 2026-08-04 P5-PCS — Gate evaluation order is fixed table order. Destructive → authorization → obligation-removal → auto-merge-widening → explicitly-accepted; first failing gate sets process exit; stdout/report lists all gate results. Authority: PCS spec judgment (e). Revert: arbitrary gate order or stop-at-first without full report.
D-117 2026-08-04 P5-PCS — Per-delta classification is mutually exclusive at the case level. A case may emit multiple deltas (different rule/subject identities). Each delta kind is assigned by priority: missed intervention > uncovered > newly-auto-mergeable > score-threshold > stricter-added > explanation-only (no free-text "other"). Unclassified real differences remain ErrUnclassifiable (fail-closed). Authority: PCS spec judgment (f). Revert: overlapping kinds or silent "other" bucket.
D-118 2026-08-05 P5-PCS-S09 — PCS autonomous exit gate CLOSED; D-057 deferred scope shipped. hack/compare/exitgate_test.sh (wired in task check) runs git diff schemas/ guard, assent compare --suite on examples/comparison/{promotion-gates,wording-only}, and E6 single-dir seed regression (examples/comparison/e6-seed/); S01–S08 + corpus CI dogfood green; backlog/later-phases mark PCS AUTONOMOUS COMPLETE; D-112–D-117 cited; D-057 cross-ref updated. Residual: compare enforces per-delta acceptedDeltas identity allowlisting only — it does not replace human review of allowlist rationale prose. git diff schemas/ == 0. Revert: drop exit gate script + reopen PCS backlog status.
D-119 2026-08-06 REL-07 — snapshot changed-file completeness (ADR-0020 Proposed). Checkout-less runs' sole .assent/** detector was truncation-blind (unpaginated deprecated /changes, no overflow/changes_count decode, 404→empty) — starvable D-042 self-vouch guard (audit P1). Contract: paginated /diffs + changes_count cross-check + page ceiling; unprovable completeness → forge.Snapshot.ChangedFilesComplete=false + gap reason → changeset opaque → fail-safe REVIEW (changeset.undecidable, record + thread, never approve/merge); visible .assent/** in a partial list still BLOCKs; diff-endpoint 404/non-200 → hard error, zero writes; fake forge + conformance suite must model truncation and 404. Checkout mode (D-077) unaffected. Authority: ADR-0020. Revert: supersede ADR-0020.
D-120 2026-08-06 ARCH-03 / OQ-9 residual — toolDigest derives from Go build info, not the version string. pins.toolDigest becomes sha256: over the canonical debug.ReadBuildInfo text (module path/version/sum, dependency sums, vcs.revision, vcs.modified) — a deterministic build-content proxy: same-version different-content builds now differ; constant within one binary (determinism gate safe); works for go install, source, and goreleaser builds incl. 0.0.0-dev. Fallback when build info is unavailable: sha256("buildinfo-unavailable\n"+version). Known honest residual: dirty-tree edit content is not captured (flagged via hashed vcs.modified=true). Schema change is description-only on frozen v1alpha1 (annotation, not validation — published v0.1.0 records stay valid); wording per this row's companion edit. Records from pre-D-120 builds carry sha256(toolVersion) — identifiable by matching that digest. Revert: restore version-string digest + old description.
D-121 2026-08-06 ARCH-04 — canonical-hash split codified; assent-jcs-v1 wired into the replay-bundle digest (corrects D-114 drift). Rule: digests over BYTE artifacts (policySha over policy bytes, marker occurrence over judged head bytes, marker decision over emitted DecisionRecord bytes, toolDigest) are raw sha256:<hex> — byte identity is the point; digests over SCHEMA-OWNED JSON DOCUMENTS that consumers re-parse and re-verify use internal/core/hash.Digest (ADR-0017 §9 domain separation) with the schema $id as domain. Exactly one digest switches now: compare.ReplayBundleDigest → hash.Digest("https://assent.dev/schemas/decision/v1alpha1/replay-bundle.schema.json", raw) (D-114 claimed this; implementation used undomained sha256(json.Marshal(decoded))). Migration (pre-v1, same commit): regenerate every replayBundleDigest in examples/comparison/*/suite.yaml; caseIds/bundles unchanged (D-113 immutability preserved — algorithm versioned by this row). ADR-0019 marker grammar untouched. Revert: restore undomained digest + old corpus pins.
D-122 2026-08-06 REL-08 — DecisionRecord emit precedes Reconcile; emit is atomic and fail-closed. New invariant: NO forge write without a schema-valid, durably-emitted DecisionRecord. Order in orchestrate: build → marshal → schema-validate → EMIT (--emit: write <path>.tmp same-dir + os.Rename; stdout: unchanged) → Reconcile → summary. Emit failure → hard error, zero forge writes. Pure reordering: recordJSON is fully determined pre-Reconcile (receipt lives in the summary line, not the record) — byte-identical records, marker digests and determinism gate unaffected. Rejected: post-reconcile record stamping (breaks record byte-stability vs marker decision digest). Revert: move emit back below Reconcile.
D-123 2026-08-06 ARCH-01 — boundary enforcement automated (depguard + extended purity walk); ADR-0011 Amendment 3 truths the "arch-lint enforced" claim. Two layers: (1) golangci depguard deny-rules — internal/core/**, internal/change/**, internal/glob, internal/lint, internal/catalogue, internal/evaldecode, internal/compare, schemas/** may import none of internal/forge/**, internal/render/**, cmd/**, net/**; (2) TestCorePurity walk extends to ../evaldecode, ../compare, and ../../schemas (call-level: time.Now/os.Getenv/os.Environ/rand/net, adversarial self-test retained). Scope note: this EXTENDS the AGENTS.md rule-7 pure tree — internal/evaldecode (engine input decode) and internal/compare (D-116/D-117 gate determinism) join the determinism guard; schemas is embedded compile-time authority. Acceptance: a synthetic violating import/call fails CI both ways. Revert: drop depguard rules + walk dirs and re-amend ADR-0011.
D-124 2026-08-07 AUD-S06 residual — the two docs truth-lag gates exist but are UNWIRED; wiring is assigned to Lane B. hack/docs/readme_smoke_test.sh (REQ-AUD-S06-01, executes every README quick-start command) and hack/docs/truthlag_pins_test.sh (REQ-AUD-S06-02, 18 grep/diff pins over DOC-05/06/09/10/11 + the ADR status index) are green and mutation-proven, but nothing invokes them: Taskfile.yml and .github/workflows/** belong to Lane B (AUD-S02/S03/S09/S14), so AUD-S06 could not add them. Consequence, stated plainly: until they are wired, a future README or docs edit reopens DOC-07 (a quick-start that exits 2) or any pinned claim with NOTHING going red — the mechanism is a manual gate, which is the same class of defect AUD-S06 exists to close. Assignment: Lane B adds a docs-gates task (bash hack/docs/readme_smoke_test.sh && bash hack/docs/truthlag_pins_test.sh) as a check: dependency, following the hack/compare/exitgate_test.sh precedent (D-118), alongside its AUD-S09/S14 workflow work. Extend when wiring (known pin gaps, both accepted for now): (a) the DOC-09 walkthrough check asserts banner PRESENCE, not polarity — flipping a step's Planned to Shipped stays green; (b) the DOC-05 link-resolution loop is scoped to README.md, so the relative links in examples/README.md are unpinned; (c) AUD-S05's TestNoStaleProductClaims (cmd/assent/main_help_test.go, Lane A5's file) walks only cmd/, internal/ and docs/ — markdown under hack/, .github/ and test/ is grepped by no pin at all. Known unfixed truth-lag, deliberately not corrected in AUD-S06 to keep the reviewed diff narrow — fix when next editing these files: (i) docs/planning/meta-plan.md closes the Phase-5 epic table with "Ordering constraint: E7 starts early (alongside E1)", directly under the new heading asserting the table is the numbering that actually executed — E7 in fact landed after E6 (E6 tip ec91226 is an ancestor of E7-S08 f27457d, both 2026-08-04); (ii) docs/usage/install.md credits a stamped version to "the Homebrew bottle", but .goreleaser.yaml's brews: block publishes a Formula whose url_template points at the release archive — no bottle is built or hosted; the version claim is true, only the term is wrong. Revert: delete the two scripts and reopen DOC-05/06/07/09/10/11 as live findings.
D-125 2026-08-07 AUD-S02 judgment call (b) — the CHANGELOG drift gate runs in task check on every local commit and in CI on push-to-main + schedule, NOT on pull_request. The spec asked for both placements; the PR placement is not merely noisy, it is red by construction. Evidence (reproduced locally, not reasoned): hack/release/verify-changelog.sh diffs the WHOLE generated changelog against the committed file, and cliff.toml's parser list ends in a catch-all { message = ".*", group = "Other" }, so merge commits are rendered — merging a probe branch put - Merge 1234567 into 89abcde in the generated output. On pull_request, actions/checkout checks out refs/pull/N/merge: a merge commit minted at CI time whose subject is exactly that shape. No committed CHANGELOG.md can contain a line naming a SHA that did not exist when it was written, so a PR-scoped step fails on every PR with no author fix (and would push commit SHAs into the changelog, contra D-101). Walk-back taken under the spec's decide-and-log sanction, using the release-exitgate precedent already in the file: if: github.event_name != 'pull_request'. Consequence, stated plainly: PR CI does not catch changelog drift. The check: entry does — one commit later, by construction: task check is green at HEAD, the next commit makes CHANGELOG.md stale, and the following task check is red until task changelog-write is committed. Push-to-main is the backstop, so a lane that lands without regenerating reds main until a regeneration commit follows. Working rule: a regeneration commit must be subject-prefixed :memo: chore(release): or :wrench: chore(release): — the two forms cliff.toml skips — or it creates fresh drift itself; and a lane must regenerate AFTER its last content commit and after any git merge origin/main. Two companion changes ship with it: version headings render Keep-a-Changelog style (## [0.1.0] - 2026-08-05, matching the hand-written [0.0.0] stub), and the D-120 pins.toolDigest record-consumer warning lives in cliff.toml's [changelog] header — CHANGELOG.md is regenerated in full, so a hand-edit there would be wiped by the next changelog-write with the drift gate still green. Closes the D-124 assignment in the same check: list: docs-gates (hack/docs/readme_smoke_test.sh + truthlag_pins_test.sh) and lint-depguard-test (hack/lint/depguard_test.sh) are now sequential check: commands — sequential, not deps:, because go-task runs deps in parallel and the smoke test builds a binary while fmt rewrites the tree. The depguard proof is local-only: CI lints via golangci-lint-action, which leaves no binary on PATH for a later step, and that gate refuses to skip when golangci-lint is absent. D-124's known pin gaps (a)/(b)/(c) and truth-lag items (i)/(ii) are NOT addressed here and remain open. Revert: drop the check: entries and the verify.yaml step — reopens RELSE-01 and D-124.
D-126 2026-08-08 D-031 domain string — DEFERRED (operator ruling). Operator explicitly deferred picking the owned domain to replace assent.dev in apiVersion/$id fields across schemas, tests, and API_STABILITY.md; assent.dev stays in place for now. Does not block future releases — v0.1.0 (D-111) already shipped with this open, so it's a contract-honesty residual, not a release gate. Revert: n/a — revisit whenever the operator picks a domain.
D-127 2026-08-08 SEC-05 PAT rotation — SCHEDULED, not immediate (operator ruling). Operator chose to schedule the HOMEBREW_TAP_GITHUB_TOKEN classic→fine-grained PAT rotation for later rather than doing it this session (requires a browser step in the GitHub UI). The broad-scope classic PAT remains live in the interim — accepted as a bounded, known risk. Revert: n/a — stays a standing operator task until done.
D-128 2026-08-08 AUD-S13 — the D-010 coverage floor is raised 90% → 91%, SINGLE-SOURCED in Taskfile.yml's COVERAGE_MIN so the CI half follows automatically. (Headline corrected 2026-08-09: it previously read "local gate only; the CI half is NOT raised here" and then reversed itself in its own "Split-gate CLOSED" paragraph below. The split-gate half of this row is the current state; the "local only" phrasing described a fence that had already lifted when the row was written.) Operator ruling, taken in the same change AUD-S13 lands. Rationale: the aggregate ./internal/... total sat at 90.28% against a 90% floor, so the gate had stopped MEASURING and started STEERING — any lane that added a branch without a test reddened task check for a reason unrelated to that lane, and the cheapest green was a filler test. Supersedes the AUD-S13 spec line "Not in scope: raising the D-010 floor" (openspec/specs/p5-aud-audit-remediation/spec.md, story AUD-S13) and its goal-(4) parenthetical "gate floor stays 90% per judgment call (e) unless the operator raises it" — the operator raised it; read the two together, not as a contradiction. Measured — re-measured 2026-08-09, and the figures this row shipped with were WRONG. The row originally said "90.28% → 91.010% (4479→4515 of 4961 statements)". Those were true against the main this lane first branched from; the D-131 lane then landed and the denominator moved, and the row was left behind — a docs-truth defect of exactly the class this epic exists to close, shipping inside the epic. Direction, stated so a reader knows which way the error cut: the stale figures UNDERSTATED the margin, so anyone acting on them was over-conservative, never under. Re-measured against origin/main at 7513d79 and at the lane head: 90.4041% → 91.0810%, i.e. 4541 → 4575 of 5023 statements (+34 covered) from the three TEST-02/05/06 behavior families, no filler. The denominator is identical on both sides (5023) — AUD-S13 adds _test.go files and no production statements, and that unchanged denominator is the proof. This is the SECOND re-measurement inside this one lane — main moved once for D-131 and again for the provider containment lane (PR #36, 7513d79), and both times the previously-written figures went stale within a day. That is the rule below earning its place: what is durable is the FORMULA, not the pair of integers. The real margin, stated as a RULE this time so it cannot rot again with the next denominator move. "91%" is nominal. The gate does not compare the raw ratio: it compares the one-decimal total go tool cover -func prints (pct=$(… awk '/^total:/ …') yields the string 91.1, not 91.0810). Anything that rounds to 91.0 therefore passes, so the effective floor is ~90.95% and the true minimum is ceil(0.9095 × N) covered statements for whatever the current denominator N is — recompute it from cov.out, never transcribe it from here. Dated instance, 2026-08-09, N = 5023: the true minimum is 4569 (4569 → 90.9616% → prints 91.0 → PASS; 4568 → 90.9417% → prints 90.9 → FAIL). AUD-S13 leaves 4575, so the slack is 6 statements, not the 0.08pp the raw ratio suggests. The rounding behaviour is inherited from the pre-existing gate design (D-010), not introduced here, and is left as-is. Net: the gate is now genuinely load-bearing — the next lane that adds more untested statements than the current slack (six, at N = 5023) reds task check, which is the intent. If that proves disruptive the answer is more behavior tests, not a lower floor. Split-gate CLOSED, and closed by SINGLE-SOURCING rather than by editing two numbers. The floor briefly existed twice: Taskfile.yml said 91% while .github/workflows/verify.yaml re-implemented the whole gate inline and still said 90% — AUD-S13 was fenced out of .github/workflows/** while PR #25 (AUD-S09/S14) owned it. #25 merged 2026-08-08, the fence lifted, and rather than raise a second literal (which would only reset the drift clock) the CI step now delegates to task coverage: the install Task step moves above it, and the step body is just task coverage. The threshold therefore lives in exactly ONE place — Taskfile.yml's coverage: task var COVERAGE_MIN: 91, interpolated into the echo and the awk comparison, with the desc naming the var instead of the number. verify.yaml now carries no coverage threshold at all, so the two halves are structurally incapable of skewing. Proven both polarities: COVERAGE_MIN: 99 reds the gate with the right message (so the templated var is really load-bearing — an unrendered {{.COVERAGE_MIN}} would make min+0 == 0 and pass everything), and hack/lint/workflow_pins_test.sh stays green across all 39 controls, including "both Task installs interpolate a single workflow-level TASK_VERSION" after the step move. Consequence: raising or lowering the floor is now a one-line edit and CI follows automatically; re-inlining the shell into verify.yaml would silently reopen the skew, so the step carries a comment saying not to. Revert: restore the inline coverage shell in verify.yaml and the literal thresholds in Taskfile.yml / GUIDELINES.md.
D-129 2026-08-08 OQ-28 (P1) CLOSED — repo-file / resource-owner reads are contained to a symlink-safe root, and a symlinked candidate is REFUSED, not followed. builtin/repo-file held only PATH containment (cleanRel/underAnyRoot, pure string guards); os.DirFS is documented as not a security boundary and fs.Stat follows links, so the invariant "never a fact from outside the declared roots" was false in production. Reproduced two ways on the live assent run --checkout route (the tree is the MR HEAD — content authored by the contributor): (1) directory symlink topics/evil -> <outside> with Roots:["topics"] → resolved value from off-tree; (2) file symlink at a wholly legitimate in-root path topics/prod/quota.yaml -> /abs/host/cluster-secrets.yaml → resolved 31337, and end-to-end an APPROVE for a change no in-repo quota allows. Reach was arbitrary absolute host paths (narrowed only by "must be a YAML/JSON mapping carrying a declared output name"), with render.displayFactValue as an exfiltration seam for non-sensitive values. Two layers, different guarantees. (a) INJECTION SITE — cmd/assent/provider_host.go checkoutFS now returns builtin.OpenRepoRoot(dir) = os.OpenRoot + (*os.Root).FS(): a syscall-level boundary, no read outside the checkout root for ANY consumer of that FS, closed for resource-owner for free; the *os.Root is closed by resolveRunFacts, and a --checkout that cannot be opened as a root is now a HARD error (loud, no decision emitted) instead of a silent degrade. (b) BUILTIN — classifyCandidate Lstats EVERY path component of a candidate and refuses on any symlink: this is the only layer that can protect the declared Roots CLIP, which os.Root cannot see (topics/prod/quota.yaml -> ../../secrets/quota.yaml never leaves the FS root yet leaves the roots), and it also catches the directory-symlink form under a bare os.DirFS, where an Lstat of the leaf alone reports an ordinary regular file. RepoFileOpts.FS now carries the documented contract "must be a symlink-safe root"; layer (b) is defence in depth, NOT a substitute — it can only observe what the injected FS reports. In-root symlinks are refused too (not followed): one rule, no per-link reachability reasoning, and it is the rule that keeps the roots clip honest; no fixture, example or corpus file in the tree is a symlink, so nothing legitimate regresses. Refusal shape: unavailable with a contributor-readable reason naming the refused candidate — same fail direction and same class as the existing "anchor outside declared roots" (the operator's config is well-formed; the repo content is hostile), and never the raw OS text (path escapes from parent) which would leak a Go internal into an MR comment (ADR-0012). The refusal STOPS the walk-up rather than skipping to a less-specific file: skipping would silently mask an escape behind an ordinary-looking resolution. Sibling assessment: LoadResourceOwnerMap reads the same FS with NO roots at all and decides who may approve — it refuses a symlinked registry with an error (no client → the owner fact never resolves). Checkout residuals — TWO of them, both LIVE, and both worse than this row first said. As first written this row claimed cmd/assent/checkout.go shares the assumption but that "filepath.WalkDir does not follow directory symlinks, so only a final-component file symlink escapes there". That sentence is materially false and is corrected here rather than left as the thing a maintainer reads when deciding whether to tag: it understated a P0. collectTree/readIfPresent are NOT fixed in this lane — they are E1-S08 fold semantics — and are being fixed in a sibling containment lane. (P0) collectTree (checkout.go:121) returns a SILENTLY TRUNCATED map. The errors.Is(err, fs.ErrNotExist) tolerance wraps the entire WalkDir, not just a missing root, and WalkDir aborts the whole walk on the first callback error. A dangling symlink is visited as a non-directory entry (WalkDir Lstats), the callback's os.ReadFile follows it and fails ENOENT, the walk stops, and the partial map is returned with no error. WalkDir visits in lexical order, so a dangling symlink whose name sorts before .assent (any leading byte below 0x2E — !evil, -evil) erases .assent/** from the changed-file set entirely: the .assent/** dominance class and the opacity flag both disappear. The reviewer drove this end-to-end through runRun --checkout and turned a BLOCK into an APPROVE, defeating the D-042 self-vouch guard — the same starvation class as D-119/REL-07, reached by a different mechanism. (P1) readIfPresent (checkout.go:129) is a bare os.ReadFile, which follows symlinks, and run.go:279 overrides the governed subject's base and head bytes from it whenever --checkout is set. A head-side file symlink therefore makes an arbitrary off-tree host file the document under judgment; the reviewer confirmed both APPROVE-manufacture and verbatim exfiltration of the host file's contents into the posted thread. Neither residual is mitigated by anything in this row: the os.Root containment landed here is on checkoutFS, a different read path. Amendment (2026-08-09, cross-lane collision with D-133) — the live-path REPRODUCTION in this row is SUBSUMED; the guard is not. It is now defence in depth, and this amendment names the condition that makes it load-bearing again. D-133 landed on main (PR #39, 71b573e) AFTER both reviewers had approved this lane, so no reviewer saw the interaction. It refuses ANY symlink anywhere under base/ or head/ at changed-file ENUMERATION — run.go step 5b (foldCheckout → collectFS) — which runs BEFORE providers resolve (step 5c) and returns a hard error. So the sentence above, “Reproduced two ways on the live assent run --checkout route”, no longer re-runs on that route: the tree is refused at enumeration with exit 1, zero forge writes, and refusing "topics/prod/quota.yaml" … reached through a symlink. That sentence stands as HISTORY — it was true when written and it is why this fix exists — but a maintainer re-deriving it today meets D-133's refusal and must NOT conclude that the D-129 fix is what stopped it. No alternative live route exists, and that was MEASURED, not reasoned. The tree providers read and the tree the enumerator walks can diverge only where root/head is not a directory, in which case checkoutFS falls back to builtin.OpenRepoRoot(root) — and in every such shape dirCheckout cannot read a head/ side, so the governed subject degenerates and the decision stops discriminating. Measured on both sub-shapes with an off-tree 31337 planted where only the provider can see it: with NEITHER side present, a poisoned tree and a clean tree emit BYTE-IDENTICAL records ("decision":"REVIEW", code: changeset.undecidable); with base/ only, likewise byte-identical (code: fileEvent.unmatchedDelete). A test on that route would pass whether or not containment works — precisely the wrong-reason pass this lane's guard comment exists to forbid. Option (b) — exercise provider containment without passing through the enumerator — therefore does not exist. The relationship is defence in depth, not redundancy, and it is DIRECTIONAL. D-133 is the outer layer and fires first today; ADR-0008 Amendment 2 names its own revisit direction as folding the refusal OPAQUE (fail-safe REVIEW with a resolvable thread) rather than following the link. The moment that fold lands, a run CONTINUES with a symlink present, providers resolve, and this row's guard is the only barrier left between the merge request and an off-tree fact. The coverage is therefore not dropped, and not documented-away: it is reshaped and armed. TestRunCheckoutSymlinkFactEscapeRefused becomes TestRunCheckoutSymlinkRefusedAtEnumeration, asserting the subsumption on the live path — exit ≠ 0, the reached through a symlink MESSAGE CONTRACT plus the refused repo-relative path (never the side directory: which side is walked first is behaviourally irrelevant), no 31337 on stdout or in the posted thread, zero forge writes — and carrying the TRIPWIRE: when the exit code becomes 0 its failure message states that the fold has landed and hands the engineer the successor test by name. Simulated by folding foldCheckout's error opaque instead of returning it: the case reds with exactly that message over a REVIEW/changeset.undecidable record while the successor stays green, so the hand-over demonstrably works. The successor, TestResolveRunFactsRefusesSymlinkedQuotaCandidate, keeps the DISTINGUISHING assertion intact at the deepest seam the live path still reaches. It drives resolveRunFacts — the exact function runRun step 5c calls — over the same planted tree, traversing checkoutFS → providerCallFor → the SOLE production builtin.RepoFileOpts construction site (cmd/assent/provider_host.go:213) → CallRepoFile's walk-up → ResolveFactsChecked → ToAggregateFact; the FS, the roots, the anchor and the declaration all come from production code, none of it hand-wired. That is strictly deeper than the two pre-existing cmd-edge pins: TestProviderHostInjectsSymlinkSafeRoot is a single fs.ReadFile through checkoutFS (layer (a) only, no provider in the picture), and TestResourceOwnerRegistrySymlinkInCheckoutRefused documents itself as a both-layers-together outcome on a different builtin. The distinguishing logic survives verbatim: a legitimate topics/quota.yaml (24) sits on the walk-up path, so a fix that merely SKIPPED the symlinked candidate resolves 24 — an ordinary-looking success that silently masks the attack — and only a refusal that STOPS the walk-up leaves the fact unresolved. The two layers are pinned SEPARATELY, not as a conjunction gate — the blur D-133 was itself corrected for. An ABSOLUTE off-tree link is refused by EITHER layer, and pins the combined outcome plus the no-31337-in-the-fact-reason property; a RELATIVE in-root link (topics/prod/quota.yaml -> ../quota.yaml) is FOLLOWED by (*os.Root).FS() because it never leaves the root, so classifyCandidate's refusal is the only barrier — layer (b) in isolation. Mutation-proven, each alone: deleting classifyCandidate's symlink arm reds BOTH poisoned subtests with FACT ESCAPE: … resolved to 24 (the skip-and-fallback outcome) while the control subtest stays green, so this is not “refuse everything”; deleting collectFS's symlink arm alone leaves the tree refused by the non-regular-file arm (refusing non-regular file …) and reds the enumeration case on the MESSAGE contract, which is the honest reading of what that arm pins. No new decision row was minted. Nothing about the guard, its shape or its fail direction changed — only which layer fires first, and where the proof now lives — so this belongs to the row that owns the guard, not to a D-135. Cross-reference owed elsewhere, deliberately NOT taken here: ADR-0008 Amendment 2's “loosening this means folding the refusal opaque” should add that the fold RE-ARMS this row's provider guard, and the D-133 row should record the subsumption from its side. Both are already merged on main, so that edit belongs to the next lane that touches them rather than to a rewrite of published rows. Spec: REQ-E5-S07-03 / REQ-E5-S08-03. Revert: restore os.DirFS in checkoutFS and drop classifyCandidate — reopens OQ-28 as a live P1.
D-130 2026-08-08 The resource→owner registry loads from the TARGET ref first; the checkout is a fallback ON ABSENCE ALONE, never a shadow (GUIDELINES §Safety 2+3 / ADR-0015 §1). Found while assessing the D-129 sibling: loadResourceOwnerRegistry tried repoFS BEFORE client.FileAtRef(targetRef), and under --checkout repoFS is the merge request's own head tree — so an MR could ship governance/owners.yaml naming its author as owner of the resource it is changing and satisfy an ownership obligation with a registry it wrote itself. That registry is a decision input (it decides WHO MAY APPROVE), so it must come from the target ref like every other deciding document; only the material under judgment comes from the MR branch. Order is now target ref → (on absence only) checkout → error. Reproduced by mutation: with the old order the loaded owner is attacker, with the new order team-payments, same inputs (TestResourceOwnerRegistryLoadsFromTargetRef). Compat: repos/hermetic runs whose target ref carries no registry still load the checkout copy (TestResourceOwnerRegistryFallsBackToCheckout); both sides missing stays an error, never an empty map that would make every resource unowned. No test, example or dogfood fixture depended on the old precedence (the live resource-owner path had no cmd-level test at all before this row). loadResourceOwnerRegistry now takes a one-method refFilePort instead of the whole forgePort, which is what made the precedence unit-testable. Amendment (independent security review, same lane) — the fallback is gated on ABSENCE, not on "the forge answered badly", and this row's own "never a shadow" claim was false as first landed. The code fell back to the checkout on every FileAtRef error, so a forge that merely failed — 401, 429, 500, 502, 503, an expired token, a proxy hiccup — handed the who-may-approve document to the merge request's own head tree, with no error surfaced and nothing in the decision to show it happened. Reproduced: a 503 on the modify-plus-transient-error shape (a plain value diff on an existing registry) resolves owner = "attacker". The 404 shape is separately mitigated — a whole-file registry add folds opaque → REVIEW — which is precisely why the modify shape was the live one. That is a fail-OPEN under GUIDELINES §Safety 2 and the exact shadow this row claimed was impossible. The gate is now errors.Is(err, forge.ErrNotFound), the neutral port sentinel (internal/forge/port.go, already wrapped with %w by the GitLab adapter) — deliberately not gitlab.ErrNotFound, which would red the ARCH-02 / depguard boundary gate (D-123). Outcome, corrected — this amendment first said "any non-absence error → … → fail-safe REVIEW", and that is NOT what ships. The error is not swallowed into an unresolved fact: it propagates loadResourceOwnerRegistry → providerCallFor → resolveRunFacts → orchestrate, so the run ABORTS. Measured on the live runRun path with a forge that 503s the registry read only — one run against a fresh MR: exit code 1, nothing on stdout, and on stderr assent run: resolve providers: provider "owner": resource-owner registry "governance/owners.yaml" at ref "main": gitlab: get file "governance/owners.yaml" at ref "main": unexpected status 503; approvals=0 merges=0 discussionsPosted=0 notesPosted=0 notesUpdated=0. That is more conservative in what assent WRITES — no APPROVE, no arming, nothing written — but NOT strictly more conservative in effect, and "strictly" was itself an overclaim minted by the previous correction of this sentence. It matters because this row exists to inform a future safety choice, so a false premise here is worse than the error it replaced. Measured — two runRun calls against ONE fakeGitLab, which models the forge and the MR rather than the run, so the carry-over is genuine MR state and not test leakage: run 1 serves the registry, decides APPROVE, and posts one summary note the MR displays as **✅ Policy evaluation** … **Decision:** APPROVE; run 2 then 503s on the registry read and leaves the MR EXACTLY as run 1 left it — notes=1 still carrying that ✅ … APPROVE body unrevised, notesUpdated=0, discussionsPosted=0, no thread — on top of the red job above. The same two runs with ONLY the registry content changed, so the resource is unknown and run 2 decides REVIEW, instead upsert that same note to **📋 Policy evaluation** … **Decision:** REVIEW (notesUpdated=1, note count still 1) and post one unresolved discussion (discussionsPosted=1). The ABORT shape was measured in both arming postures — arming eligible (run 1 also writes approvals=1 merges=1) and arming refused/advisory (approvals=0 merges=0, so the stale ✅ summary is the ONLY thing the MR carries) — with the identical stale-summary result; the REVIEW comparison above was run in the arming-eligible posture only. Read, not measured (a fake forge cannot demonstrate merge behaviour): that unresolved discussion is what HOLDS the merge wherever assent can arm at all, because PreconditionFromCapabilities refuses arming unless only_allow_merge_if_all_discussions_are_resolved is enabled (internal/forge/precondition.go:63-68 → ArmEligible → reconcileApproveMerge's ErrArmingRefused, internal/forge/forge.go:780; polarity pinned by TestRunApproveUnarmedNoWrite) — and the abort supplies none. So the gap is not the UX gap this row previously described ("there is nothing in the MR explaining why"): the artifact a human reads still says APPROVE, and nothing assent wrote holds the merge. It is not an auto-merge risk — an aborted run arms nothing and writes nothing — it is a stale-green risk on the human path, and it bites precisely on the rerun-after-a-green-run shape. The rest of the earlier wording stands: a contributor hitting a flaky forge gets a red CI job with no DecisionRecord and no thread at all, not a reviewable REVIEW thread. Anyone changing this must choose between the two deliberately; today it is the abort. Test: TestResourceOwnerRegistryTransientForgeErrorNeverFallsBackToCheckout (500/502/503/429/401), mutation-proven red at owner = "attacker" before the gate landed, plus TestResourceOwnerRegistryForgeErrorAbortsResolveRunFacts, which pins the propagation one level up — providerCallFor → loadResourceOwnerRegistry → resolveRunFacts, wiring that was correct but that NO test in the tree entered (builtin/resource-owner appeared in no config, example, exit gate or run test), mutation-proven red on both links (return nil, err → return nil, nil in providerCallFor's resource-owner arm; the provider %q wrap → continue in resolveRunFacts), and red for no other test in the repo. The three pre-existing registry stubs moved from errors.New("404 file not found") to the adapter's real wrapped shape: "any error opens the fallback" was the defect, so a stub asserting it would have stayed green over the fix. Sibling checked and deliberately NOT changed, recorded so the next reviewer need not re-derive it: the host-declaration load at the head of resolveRunFacts's provider loop continues on ANY FileAtRef error — the same absence/error conflation this amendment closed for the registry — but it is fail-safe by a DIFFERENT mechanism, re-confirmed empirically through runRun on an ownership policy proving facts.owner.owner.state == 'resolved' (declaration present + registry served from the target ref → APPROVE on a genuinely resolved owner fact, approvals=1 merges=1; declaration 404 → REVIEW with effect: require-review, code: predicate.error, one thread posted, approvals=0 merges=0), because a skipped provider leaves the fact ABSENT, the CEL bound reference errors, and the engine routes a predicate error to require-review — a decision and a thread, not an abort. The earlier reading of that control was DEGENERATE, and it is corrected rather than restated. It ran against cmd/assent/provider_host_registry_test.go's host-declaration fixture, which declared output team at maxAge 1h while builtin/resource-owner emits owner at 24h (OutputOwner / OwnerDeclaration()), so ResolveFactsChecked's DeclarationsEqual check synthesized state:"invalid" / "provider echoed declaration does not match host config" on every run — the APPROVE arm therefore said nothing about a healthy resolve. Measured on that fixture: the run emits APPROVE with ZERO findings — the same output a run with no provider configured at all would produce — because the probe rule referenced facts.owner.team on an MR that already carried a human approval. The mechanism (the FALSE predicate's require-review onFailure being satisfied by that standing approval evidence) is INFERRED, not separately measured here; its opposite polarity is pinned by TestRunNilEvidenceRequiresReview. Re-measured with the rule pointed at the fact the builtin actually emits: the broken fixture yields REVIEW/predicate.error with a thread and facts used: owner.team=invalid — indistinguishable from the 404 arm — while the corrected fixture yields APPROVE on a resolved fact. The fixture is corrected in this lane and the healthy resolve is pinned by TestResourceOwnerDeclarationResolvesOwnerFact (mutation-proven red on both the output name and the maxAge), so the control now discriminates. The skip, fail-safe though it is, remains SILENT (pins.factsResolvedAt is simply {} and no reason is emitted), so an operator cannot distinguish "the provider is not declared for this repo" from "the forge failed to serve the declaration"; noted, not fixed here. Spec: REQ-E5-S08-03. Revert: swap the order back, or widen the gate to any error — either reinstates the shadowing fail-open.
D-131 2026-08-08 Engine P1 fail-open — an ordering operator over a TEXT operand is an evaluation error, not a lexical answer (ADR-0013 Amendment 1). CEL defines < <= > >= over strings as a lexical compare, so a when: new >= old leaf answered a boolean whenever its operands bound as text — and both answers are wrong. Reproduced end-to-end through the production entry point (change.Diff → evaldecode.DecodeCanonical → aggregate.Cover) on a routine adopter shape, a QUOTED numeric: partitions: "12" → "6" is a !!str the differ tag-discriminates and the decoder keeps a Go string BY DESIGN, so the D-016 partitions-must-not-shrink rule evaluated the lexical "6" >= "12" = true, proved non-destructive, fired nothing, and returned APPROVE with zero findings — a BLOCK→APPROVE flip. The mirror case is equally wrong: a legitimate grow "6" → "12" evaluated false and BLOCKed. Second, narrower instance: a numeric literal representable as neither int64 nor float64 fell back to its STRING form in toCEL, so 9e399 > 1e400 was lexically true (numerically false). Fix, at the evalLeaf/toCEL seam only: (1) evalLeaf plants a watcher (a cel-go CustomDecorator) on every operand of every relational operator; if an operand ACTUALLY evaluates to text — a CEL string OR bytes value — the leaf returns an error → predicate.error → REVIEW. Value-based, not syntax-based, and deliberately so: whether new >= old is sound depends on the adopter's DATA, not the policy text, so lint cannot catch this class (checkLeafScope/checkPredicateScope/checkFactsShape were checked; none could) — the information exists only at evaluation. Watching (not reading post-eval EvalState) is what makes EVERY comprehension iteration visible, since state keeps one value per node id. Short-circuited compares never run their watcher, so a policy cannot flip to REVIEW for a compare that did not happen. The guard is applied to both evaluation seams: evalLeaf (the E2 production path) and evalRule (the walking-skeleton Aggregate path, exported and test-only today, whose env declares old/new as StringType and binds the RAW canonical strings — so every bare relational there was lexical by construction, with only convention mandating int()). One unguarded evaluator is how this class returns; same drift argument as D-055(c). Its cost-budget test doubles as the interaction proof that cel-go still charges a decorated operand (costBombWhen is the left operand of > 0; cel-go v0.30.0). (2) toCEL binds an unrepresentable numeric literal as a CEL error value instead of its string form; an over-int64 but float64-representable literal still binds lossily — ADR-0013 residual #1's lossy half is UNCHANGED and still live. Behaviour change, stated plainly: a policy that previously evaluated (wrongly) now ERRORS. Any leaf that ordered text — in either direction, at any decision — becomes predicate.error → REVIEW. Nothing in examples/, the comparison corpus, either dogfood pack or the test corpus ordered text (all relational leaves are numeric: partitions, memory_mb, replicas, size()), so no golden, fixture or gate output changed; the only changed test is internal/evaldecode's mutation proof TestStringOldNewFailsOpen, renamed TestUndecodedStringOldNewFailsSafe — it asserted APPROVE to document the fail-open the decoder closes, and now asserts the discriminating pair (decoded → BLOCK + partition-count-shrunk; un-decoded → REVIEW + predicate.error), so neither layer can rot silently. Consequence for authors: ordering quoted numerics means coercing first — int(new) >= int(old) (already the repo idiom) or double(...); dates use timestamp(...). Ordering raw text is no longer expressible in tier-1 assert, in ANY spelling — string(a) < string(b) and the byte-wise bytes(a) < bytes(b) are the same lexical sort and are refused with the bare form — and graduates to Rego (ADR-0002 escape hatch), consistent with ADR-0013's "don't grow a programming language in YAML". Equality, in and the string functions are untouched. Scope call: the guard is on evalLeaf only, NOT on EvalScalar — {{ }} message interpolation renders text and decides nothing, so guarding it would widen the blast radius for no safety gain. Records: no openspec/ change entry — openspec/changes/ holds only its README (this repo has never used change proposals in practice), and a fail-safe correction of an existing frozen semantic is proportionate to a D-row + ADR amendment + failing-test-first. Revert: drop textOrderGuard and restore toCEL's x.String() fallback — reopens the APPROVE flip. Review close-out (three non-blocking notes, decided by the author): (F-1) the first cut watched only types.String, so bytes(a) < bytes(b) — the IDENTICAL byte-wise compare — slipped through and bytes(new) >= bytes(old) over the quoted "12" → "6" shrink still answered true. Not accident-reachable (the defect class is a quoted numeric in adopter YAML, not a deliberate bytes() call), so the choice was real: exempt it as a sanctioned explicit escape hatch, or refuse it. Refused — the lane had already rejected exempting string(...) because it "re-admits the identical defect", and that reasoning applies verbatim; a documented exemption would also advertise a one-token workaround for the guard's own error message, converting "not accident-reachable" into "the documented way round the guard". The refusal set is now string + bytes, and it is provably COMPLETE: the _<_ overloads reachable from newEvalEnv were enumerated empirically — int/uint/double/bool/duration/timestamp are genuine orderings and pass through, list/map/type/mixed-numeric have no overload at all — so ADR-0013 Amendment 1's universal claim is now true as written rather than merely intended. TestOnlyTextShapedOperandsAreRefused pins all three buckets and reds if the env gains a comparable type (e.g. CrossTypeNumericComparisons). (F-2) the guard's refusal sentence was commented "adopter-facing"; it is not — both consumers use the error only for its non-nil-ness (coverSubject renders the AUTHORED leaf message, evalRule's caller renders none), so no surface prints it today. Comment corrected to say what the consumers do; the sentence stays plain-worded so it is fit to surface if an engine error channel is ever added. (F-3) {{ old }}/{{ new }} over an unrepresentable literal interpolate the CEL error VALUE, rendering toCEL's sentence where a number belongs. Left as-is deliberately and GOLDENED: it is authored prose, not a Go-internals leak (GUIDELINES §Contracts), it decides nothing (the leaf already errored → REVIEW), it says WHY the rule could not decide where a bare {{ old }} placeholder would not, and re-opening this row's explicit "guard evalLeaf, not EvalScalar" scope call for a cosmetic P3 buys no safety. The golden also pins the DIVERGENCE between the two interpolators (expandMessage renders the sentence; internal/render's EvalScalar path rejects the error value), so a future unification cannot change either silently.
D-132 2026-08-08 AUD-S18 — the audit exit gate, the six explicitly-accepted 2026-08-06 findings, and the ref-relative schema-freeze guard. Three things, logged together because the gate is what makes the other two checkable. (1) Six findings are ACCEPTED, not fixed — each is an Accepted row in the spec's Appendix B disposition table and this row is the authority they cite (five of them as of 2026-09-05: DOC-03's disposition moved to D-176, see the struck clause below): SEC-02 (CI check-gap, compensated by the four required CI contexts now live on main); SEC-07 (v0.1.0 assets were replaced in place twice post-publication, re-signed and re-attested consistently; the corrective is the patch-tags-not-replacement runbook line landed in AUD-S06, not process machinery); TEST-04 (cmd/assent sits outside the D-010 coverage denominator at ~75%; widening the denominator is an operator decision, and the binary dogfood gates compensate); DOC-03 (the stale operator-local AGENTS.md is globally gitignored — not in the tree, so no in-tree gate can reach it — that rationale is WITHDRAWN 2026-09-05 by D-176, which now owns this finding's disposition. The file was never gitignored, only untracked: git check-ignore -v AGENTS.md exits 1 with no matching pattern and core.excludesfile is empty at all three scopes. It is tracked as of D-176. The wording is kept struck rather than deleted because the withdrawn claim is the record); DOC-04 (planning docs out of the public mkdocs nav — by design); A-01 (glob recompile, no hot-path evidence, deferred with E3). Revert for any one of them: flip its Appendix-B row to Done and open a story. (2) The git diff schemas/ freeze guard is made REF-RELATIVE, and its scope is narrowed to schemas/**/*.json — both deliberately, both recorded here. The pre-existing guard (hack/compare/exitgate_test.sh:14-19, PCS-S09/D-118) diffs the WORKING TREE. That is silent on COMMITTED schema changes, which are the only kind that ship: the claim "git diff schemas/ is clean at the exit gate" was true and meaningless. AUD-S18's guard diffs against an IMMUTABLE base ref — the released tag v0.1.0 — so a committed schema edit reds it, and the baseline cannot be regenerated by the same commit that changes the schema (which is the property an in-tree digest manifest would NOT have). Scope narrows from schemas/ to schemas/**/*.json because schemas/ also holds Go code (compiler.go, schema_id_test.go) that AUD-S16 legitimately changed; the FROZEN artifacts are the JSON contracts. To stop that narrowing from becoming a loophole the gate additionally pins the EXTENSION SET under schemas/ to exactly {json, go} — a future .yaml/.cue schema cannot land outside the guard — and pins the JSON file COUNT against the base ref, so an addition or deletion reds too. Amended 2026-08-09 (review F4): "cannot become a loophole" was false for EXTENSION-LESS files and is now true. The extension pin listed find … -name '*.*', which never matches a dotless name, and the freeze check's $NF ~ /\.json$/ awk filter drops one from --name-status — so a committed schemas/FROZEN passed BOTH at exit 0. The extension pin now takes the BASENAME (a dot in a directory component is not an extension) and buckets a dotless name as the pseudo-extension (none), which is not in the pinned set and therefore reds, with its own mutation control. The freeze check's *.json filter is deliberately left NARROW: widening it would red on AUD-S16's legitimate schemas/*.go edits, which is the reason the narrowing exists. Exactly one diff is permitted: the D-120 toolDigest description line. The baseline is overridable via ASSENT_AUDIT_SCHEMA_BASE so it can be moved FORWARD to a later release, and only that: the check refuses any base that is not a vX.Y.Z tag (HEAD, a branch or a raw SHA would empty the diff and leave the guard vacuous), and the CI-wiring check refuses ASSENT_AUDIT_SCHEMA_BASE anywhere under .github/workflows/** for the same reason — a step-level env:, a job- or workflow-level env:, and a prior step's >> $GITHUB_ENV write all steer the baseline without touching the gate step at all. The PCS guard is left as-is (Lane owner: PCS); AUD-S18's guard is what carries the epic's freeze claim. Revert: point the guard back at the working tree. (3) The gate does NOT certify release readiness, and says so. It is scoped to the 2026-08-06 audit. OQ-27 (relational CEL over string-bound operands returns a silently wrong boolean — BLOCK→APPROVE) and OQ-28 (repo_file symlink escape) were found DURING this epic, are not audit findings, and block the tag independently; the gate pins a Post-audit release blockers section naming them and requires each cited OQ-<n> to resolve in open-questions.md, so the exit gate cannot go green while pretending they do not exist. Assigned, so this does not become a truth-lag of the class this epic just closed: whoever merges the OQ-27 / OQ-28 fix lanes flips that row's status to CLOSED in the same change — the gate requires a status token, not a particular one. Both are now CLOSED: OQ-27 by D-131 / ADR-0013 Amendment 1 on main; OQ-28 by D-133 on main (71b573e), which refuses ANY symlink under base//head/ at changed-file enumeration before providers resolve, plus D-129's provider-layer guard (builtin.OpenRepoRoot + classifyCandidate's per-component refusal), merged to main as 7513d79 (PR #36) while this lane was in review — verified against that lane's code rather than transcribed from its claim. The rows stay in the table: a closed row is the record, and the gate requires a status token, not a particular one. (4) Review hardening, 2026-08-09 — three disarm vectors closed after APPROVE, plus one anti-vacuity gap. (a) A CONSTANT job-level if: on release-exitgate is now refused. if: false skips the job, and a skipped job reports SUCCESS, so the gate stopped running while the workflow stayed green and check_ci_wiring returned 0 — reproduced. The lane's original justification for leaving if: unpinned — "no rule that admits github.event_name != 'pull_request' can also refuse if: false without being either brittle or trivially reworded around" — was overstated and is retracted: refusing a literal true/false is neither. The value is normalised first (${{ }} unwrapped, quotes stripped, comment tail dropped, trimmed, lowercased), so if: false, if: ${{ false }}, if: 'false' and if: true all red, each with its own control, and a control in the other polarity proves a genuinely REWORDED condition still passes. The EXPRESSION is still deliberately unpinned — it is not the only legitimate spelling, and deleting the if: is self-announcing (the job would run on pull_request and red on the changelog stage). Residual, NOT closed: a semantically-never-true expression (if: github.event_name == 'deployment_status') disarms the job identically and cannot be refused without evaluating GitHub's expression language; it stays fenced to RELSE-08 (AUD-RELSE-08), whose required-check setting is what actually closes it. (b) Stage BODIES are pinned by content, not only by name. Replacing the coverage: task's cmds: with echo "coverage: 99.9% (required: {{.COVERAGE_MIN}}%)" — leaving the stage in check:, vars: COVERAGE_MIN: 91 intact and the banner printing — left check_check_wiring, check_coverage_floor, check_check_stages and check_coverage_bar ALL at exit 0, the last of them grading the fabricated 99.9. coverage: is the one stage whose OUTPUT this gate consumes as evidence, so check_stage_bodies pins a real go test -coverprofile= over ./internal/..., a go tool cover -func= the total derives from, an evidence line interpolating ${pct} rather than a literal, and a comparison still reading {{.COVERAGE_MIN}} (an unrendered one makes min+0 == 0 and admits everything); lint-workflow-pins-test: is pinned to its script the same way. Same mechanism as hack/release/changelog_gate_test.sh, which already pinned four other stage bodies — not these. Five controls, including one proving the ${pct} pin is not redundant with the measurement pins (measurement kept, evidence line literalised) and one proving the extraction is not vacuous. (c) F4 above. (d) The OPEN/CLOSED status-token branch of check_post_audit_blockers was the ONE assertion in the file without a mutation control — a side effect of loosening the neighbouring control to be status-agnostic. It now has one, itself status-agnostic (it substitutes whatever token is present for a sentinel), so the file's header claim "every check is run against an input carrying the very violation it exists to catch" is true as written. Revert: delete hack/audit/exitgate_test.sh and its release-exitgate step.
D-133 2026-08-09 The local checkout is CONTENT UNDER JUDGMENT, not trusted operator input: reads are contained, symlinks are refused, and a truncated tree is never returned with a nil error. Two production defects in cmd/assent/checkout.go, both reproduced end-to-end through runRun --checkout, both fail-OPEN. (1) P0 truncation: collectTree tolerated fs.ErrNotExist for the WHOLE filepath.WalkDir, so any mid-walk ENOENT aborted the walk and returned a silently truncated map with err == nil. One dangling symlink named .aaa (sorting before .assent) on an MR that adds .assent/newpack.yaml turned "decision":"BLOCK" into "decision":"APPROVE" with three forge operations written — the D-042 self-vouch guard defeated by a single mode-120000 blob. Mechanism, measured not reasoned: filepath.WalkDir itself does not error on a dangling symlink (files=34 err=<nil> over a real cloned tree) — its DirEntry comes from the Lstat-based ReadDir. The ENOENT was raised INSIDE the callback by os.ReadFile, at arbitrary depth, and is indistinguishable BY KIND from "the root does not exist". The two are therefore separated by WHERE they arise, never by errors.Is: os.Lstat the root once up front (Lstat not Stat, so a dangling side symlink is a broken checkout rather than an empty side), then propagate every in-walk failure unconditionally. A dangling symlink is only the cheapest trigger — a permission error, a device node or a plain race truncate identically, so the guard is pinned by a NON-symlink test (TestCollectFSPropagatesMidWalkNotExist, a fixture FS that lists a file and then refuses to open it). Reachability, also measured: a repo seeded with ln -s /etc/hosts .aaa and ln -s /nonexistent/nope .aab commits both as mode-120000 blobs, and both git clone and git worktree add recreate them as real POSIX symlinks, the dangling one included. Applies to both sides: ChangedFiles unions base and head keys, so a truncated BASE hides a sibling whole-file DELETE — the E1-S08-03 fail-safe. (2) P1 substitution: readIfPresent was a bare os.ReadFile and run.go:279 overrides the GOVERNED SUBJECT's base/head bytes from it, so a head-side file symlink at a legitimate in-repo path made an off-tree host file the document under judgment: topics/orders.yaml -> <tmp>/host-owned.yaml saying partitions: 20 APPROVEd a repo whose own head said 12 unchanged, and a host value of 31337 was rendered verbatim into the posted thread (- matched change: /partitions modify 65536 -> 31337) — exfiltration on the forge-facing surface. Fixed with the D-129 idiom, one containment idiom for the codebase: os.OpenRoot + (*os.Root).FS() PLUS an explicit refusal of any candidate reached through a symlink. Both layers are required, verified not assumed: a root FS follows a RELATIVE symlink that resolves back inside the root (topics/orders.yaml -> ../decoy.yaml reads the decoy), and os.Root's Lstat reports the link rather than erroring — so the refusal must be explicit; conversely the root is what refuses a leaf swapped between Lstat and open. Refusal is an ERROR, never "absent" — silent-absent would erase the path from the changed-file set and collapse into the EFE-S03 presence signal where nil means a whole-file lifecycle event. The presence contract is preserved exactly: nil = ABSENT, non-nil zero-length = PRESENT-but-empty. Rule stated plainly: no symlink anywhere under base/ or head/. Containment is anchored AT those two directories — they are operator-provisioned and may themselves be symlinks; nothing beneath them may be. Consequences, stated plainly: (a) assent run --checkout on a real repository that legitimately contains ANY symlink now hard-errors (exit 1, zero forge writes) instead of judging it — fail-closed, but a usability regression for such repos; revisit by folding the refusal opaque (fail-safe REVIEW with a resolvable thread) rather than by weakening it; (b) non-regular files (FIFOs, sockets, devices) are refused too — previously os.ReadFile on a FIFO would have blocked the run forever; (c) the false #nosec G304 G122 rationale ("operator-supplied … not remote/attacker input"; "symlink TOCTOU is out of scope … fixing it belongs to the checkout-provisioning story") is DELETED, not rewritten — it was wrong about the trust boundary and it attributed an out-of-scope claim to ADR-0008 §4, which says no such thing. ADR-0008 gains Amendment 2 stating the boundary. Legitimate-polarity regression caught and fixed inside the lane, worth naming because task check was blind to it: fs.ValidPath rejects a rooted name, and run.go computes governed := strings.TrimPrefix(cfg.subject, "file:") without trimming a leading slash — so --subject file:/topics/orders.yaml began hard-erroring. Measured on the pre-fix reader: exit 0, APPROVE (filepath.Join cleaned the slash away). readIfPresent now normalises it exactly as anchorFromSubject does, so the two subject readers agree; it is a normalisation, not a weakening — the name stays relative to the side root, and a .. surviving path.Clean is still refused. Every fixture in the suite used the unrooted form, which is why the gate matrix said nothing; both polarities are now pinned and mutation-proven. Known residual: a leaf swapped for an IN-ROOT symlink between Lstat and open is followed; the bytes are still in-tree contributor content under the same root, so this is a within-tree substitution, not an escape — an escaping swap is refused at the syscall level (openat …: path escapes from parent). Test note: mutation testing found three guards no test could red (the walk tolerance, the root-vs-os.DirFS choice, and nil = absent, whose every existing EFE-S03 fixture omits the WHOLE side directory and so never reaches the per-file read); all three were made falsifiable and seven mutations red. Correction, logged before merge: that sentence, and commit 78df98c's title "make every containment guard falsifiable", both overstated. Two guards inside collectFS — the symlink refusal and the non-regular-file refusal — were still a CONJUNCTION GATE: each stayed green when removed ALONE and only removing BOTH reddened the suite; and openCheckoutSide's deliberate os.Lstat (not os.Stat) had no test at all. Both are closed now, with the distinction stated rather than blurred. The two collectFS arms cannot be separated BEHAVIOURALLY, and the pin says so: fs.WalkDir's DirEntry comes from the Lstat-based ReadDir, on (*os.Root).FS() and fstest.MapFS alike, so a symlink is always ALSO non-regular and the non-regular arm is a strict behavioural superset — delete the symlink arm and the tree is still refused, still exit 1, still zero forge writes. Only the WORDING differs. So the symlink arm is pinned as a MESSAGE CONTRACT (reached through a symlink, now a documented promise in docs/usage/cli.md and contributor-facing per GUIDELINES — "refusing non-regular file" does not tell an adopter their repository contains a symlink), and the non-regular arm is pinned BEHAVIOURALLY with an fs.ModeNamedPipe fixture that carries no symlink bit. Mutation-proven each ALONE: dropping the symlink arm yields refusing non-regular file "LICENSE" in checkout tree head (arm 1 reds, arm 2 green); dropping the non-regular arm collects the pipe and returns 2 entries [run/queue.pipe topics/orders.yaml] with a nil error (arm 2 reds, arm 1 green). The os.Lstat pin IS behavioural: os.Stat follows the link, so a DANGLING side symlink stats as fs.ErrNotExist — indistinguishable from the one absence this package tolerates — and the broken checkout reads as an EMPTY head, enumerating every base file as a whole-file DELETE; the mutation reds with exactly files=[.assent/p.yaml] err=<nil>, a silent mass delete offered for judgment. Its legitimate-polarity twin (a side symlinked to a REAL directory still reads) pins Amendment 2's operator-provisioned exemption, which nothing else did. Carrier note for consequence (a): a limitation counts as documented only where the person hits the wall, so it is now stated at docs/usage/cli.md's --checkout flag — scope (ANY symlink ANYWHERE under base/ or head/, including one that predates the MR and that the MR never touches; measured on the BASE side with an untouched LICENSE -> LICENSES/Apache-2.0.txt), exit 1, zero forge writes, the verbatim refusal text, the drop-the---checkout-flag escape, and the base//head/ exemption — and as a cliff.toml "Compatibility notes" entry, which is the only carrier reaching the published GitHub Release body (CHANGELOG.md is regenerated in full from commit history, so a hand-edit there is wiped by the next task changelog-write). Revert: restore the two os.ReadFile call sites and the whole-walk fs.ErrNotExist tolerance — reopens both fail-opens.
D-134 2026-08-09 --arm is ADVISORY-ONLY and has gated nothing since c05cde0; this SUPERSEDES D-041's operator-facing claim "unarmed APPROVE → advisory-only, zero writes", which was true when recorded and has been false since. D-041 is left byte-unchanged per GUIDELINES (supersede, don't edit): its evidence was gathered on 2026-07-27 against 4addc3d, which git merge-base --is-ancestor 4addc3d c05cde0 confirms is an ancestor of the commit that invalidated it — so the row was accurate on its own date and this one records where it stopped being accurate, not that it was wrong on arrival. What changed: c05cde0 (2026-08-04, :sparkles: feat(run): wire forge Snapshot/Resolve on assent run (E4-S06)) replaced Preconditions{ArmEligible: cfg.arm} with ArmEligible: armEligible, the forge-probed forge.PreconditionFromCapabilities value, closing D-034/D-074/D-078 per REQ-E4-S06-05. That engine behaviour is CORRECT and stays — it is pinned by a genuinely discriminating test, TestRunForgeProbedArmingGatesWrites, which passes no --arm and asserts the writes happen. What went unrecorded is the operator-facing consequence: a flag documented as required-for-writes silently became cosmetic. git show c05cde0 -- cmd/assent/run.go | grep BoolVar returns nothing — the commit rewrote buildDesired's own arming comment and touched neither the flag's help string nor runRun's doc comment. Measured through the production entry point runRun against fakeGitLab, not transcribed: on a forge-probe-ELIGIBLE APPROVE fixture, args=[] → exit=0 approvals=1 merges=1, summary decision=APPROVE arm=false → 3 forge operation(s) written; args=[--arm=false] → byte-identical; args=[--arm=true] → exit=0 approvals=1 merges=1. The flag's default is false, so every run that never passes --arm was in the state the help string described as safe. On a probe-INELIGIBLE fixture (C3 gate absent) both args=[--arm] and args=[] give exit=0 approvals=0 merges=0, summary … → advisory-only (arming precondition unmet, no approve/merge). The real gate is the default-deny internal/forge/precondition.go: ArmEligible requires ALL of C17 ProtectedPipelineExternal (refusal insecure-topology), C3/ADR-0009 DiscussionsResolvedGate (discussions-gate-missing), and C6/C7 HasApprovalRulesAPI && Tier != TierFree (tier-capability-gap) — reported by assent doctor — plus the .assent/** self-edit guard (measured: exit=0 approvals=0 merges=0 discussions=0, decision=BLOCK → assent-policy self-edit, no forge writes (fail-closed)), the fork/untrusted guard, ADR-0017 §4 controlling-fact freshness, completeForMerge(), and the pre-write SHA guard. Why this blocked the v0.2.0 tag: cli.md and walkthrough.md ship with the release and go to the docs site alongside it, and the unsafe path needs no operator error — follow the walkthrough, copy its CI snippet (which omits --arm), harden the project until doctor reports the precondition MET, and the pipeline begins approving and merging while cli.md still promises it will not. Re-publishing a known-false safety guarantee under a new version is a knowing act. Corrected surfaces: cmd/assent/run.go flag help + runRun exit-code comment; cmd/assent/provider_host.go lines 34 AND 102 (a repo-wide --arm sweep found a second copy of "ArmEligible stays --arm ∧ APPROVE", outside the reported finding); docs/usage/cli.md flag row + exit-codes paragraph, plus a new What gates approve and merge section carrying the three preconditions, their typed refusal codes, and how to actually stay advisory. Amended 2026-08-09 (factual-record correction, not a decision reversal — audit DOC finding): this clause as first written read "(leave a precondition unmet, or phase: observe)", and the phase: observe half is RETRACTED ~2,000 words below as ARM-08, "false in the unsafe direction, twice over". The retraction is the truth and it is restated here, at the natural stopping point, because a reader who stops after "Corrected surfaces" was leaving with the unsafe advice: the only reliable lever is leaving one of the three arming preconditions unmet. phase: observe is NOT a safety switch — with no require: on the binding it turns a BLOCK into APPROVE+merge, and a pack's spec.phase is inert unless --pack is passed. See ARM-08 below for the measurements. docs/usage/walkthrough.md needed no change IN THIS LANE, and the claim is narrowed to what was actually checked: its CI snippet already omits --arm, and no surrounding prose reintroduces the --arm claim. A second, unrelated defect was found while checking it and is fixed in this same lane under operator override — see D-135: walkthrough line 149 says that on the REVIEW path "assent had already armed the forge's auto-merge, pinned to the evaluated commit — so the moment the last thread is resolved, GitLab itself merges". Nothing observed supports that. buildDesired's REVIEW/BLOCK branch sets desired.Thread only and returns a zero forge.Preconditions{} — no Approve, no Merge, no ArmEligible — and the forge.Forge port has no merge-when-pipeline-succeeds/auto-merge operation at all (Approve and MergeCAS are the only write verbs; MergeCAS is an immediate compare-and-swap). Measured: a require-review-unproven run with --arm gives exit=0 approvals=0 merges=0 discussions=1, decision=REVIEW arm=true → 2 forge operation(s) written. This is a SECOND, DIFFERENT docs defect from the --arm one; it is recorded and corrected as D-135. Note for the next editor: the flag help string must contain no backquotes — flag.PrintDefaults reads `...` as the value-name placeholder, and a first draft rendered as -arm assent doctor. Audit finding ARM-04 closed: cfg.arm had zero discriminating coverage, and the finding was verified rather than accepted — on the pristine tree, mutating summarize(result.Decision, cfg.arm, …) to summarize(result.Decision, true, …) at run.go:447 leaves the whole cmd/assent package ok. The ~30 tests that pass --arm do so ritually; deleting the flag would red them on flag-parse error, not behaviour. TestRunArmFlagIsAdvisoryOnly now pins the arm=<bool> summary token — the flag's ONLY remaining observable effect — in both polarities, and pins the advisory semantics itself (write outcome and the rest of the summary identical with and without the flag). Mutation-proven each direction: …, true, … reds without_flag_reports_arm_false + flag_changes_nothing_but_the_token; …, false, … reds with_flag_reports_arm_true. It is deliberately NOT red-first — cfg.arm was already threaded to summarize, so there was no implementation to add and mutation-kill is the honest form of the TDD obligation here. Self-inflicted P1 caught in review and fixed before merge (finding ARM-08) — worth recording because the fix for a false safety claim introduced another one. The What gates approve and merge section this row added closed with advice to stay advisory by putting the pack's rollout in phase: observe, "which excludes its rules from the decision structurally". That is false in the UNSAFE direction, twice over. (i) Structural exclusion removes the very findings that were withholding approval. Measured over CoverWithPhaseCeiling on an enforcing BLOCK rule: with require: [signal] the ceilings enforce/observe/off give BLOCK/REVIEW/REVIEW, but with no require: they give BLOCK/APPROVE/APPROVE — observe turns a BLOCK into an approve+merge. Only an enforce-phase rule marks a required obligation covered (coverage.go:150), so the binding's require: list is the sole thing holding the line — and require is OPTIONAL in the frozen RulesetBinding schema ("Absent or empty ⇒ no required obligations (vacuously covered)"), which is precisely the state of someone rolling out their first pack, the persona the advice targeted. The repo's own internal/core/aggregate/phase_test.go already asserted DecisionApprove for this case. All three shipped adopter packs declare non-empty require, so recommended configurations were safe. (ii) A pack's spec.phase is INERT unless --pack is passed (run.go:247: the ceiling is PhaseEnforce otherwise) — and the walkthrough's CI snippet passes no --pack. Measured end-to-end against a fake serving no pack route: without --pack the run succeeds and merges (exit=0 approvals=1 merges=1), proving no pack is read; with --pack the read happens and fails loudly. So an operator following the advice literally — edit the manifest, change nothing else — stays fully enforcing and merging while believing they are advisory: ARM-01's exact shape, reintroduced by ARM-01's fix. Replaced with the measured truth (see also D-135's SURF-07 note, the root cause): there is no dry-run mode — assent run --dry-run exits 2 with flag provided but not defined: -dry-run — no phase is a safety switch (off yields APPROVE too), and the only reliable lever is leaving one of the three arming preconditions unmet. The decision matrix above is now published in cli.md rather than summarised. Explicitly NOT done, both post-tag follow-ups needing an openspec change proposal and their own red tests: making --arm gate writes (contradicts REQ-E4-S06-05, would red TestRunForgeProbedArmingGatesWrites) and removing the flag (would break working v0.1.0 pipelines for no safety gain once the docs are honest). Revert: git revert this lane — restores the false claim and changes no behaviour, since the lane changes none.
D-135 2026-08-09 The deferred forge auto-merge in ADR-0009's challenge-resolution amendment was never implemented, and docs/usage/walkthrough.md shipped it as fact — corrected to the measured behaviour; the ADR is left to its own amendment. walkthrough.md line 149 read: "They resolve the thread ('intentional, ticket TOPIC-123'). assent had already armed the forge's auto-merge, pinned to the evaluated commit — so the moment the last thread is resolved, GitLab itself merges (ADR-0009 amendment)." This is implementation-vs-design DRIFT, not a transcription slip. The walkthrough was faithfully describing ADR-0009's Amendment (2026-07-21, F2/F3), which specifies that on challenge findings assent "approves conditionally and arms forge auto-merge pinned to the evaluated SHA (GitLab: 'merge when pipeline succeeds' + all-discussions-resolved merge gate)". That mechanism does not exist in the code. Verified three independent ways, not reasoned: (1) buildDesired's REVIEW/BLOCK branch sets desired.Thread only and returns a zero-valued forge.Preconditions{} — no Approve, no Merge, no ArmEligible; (2) the forge.Forge port has no deferred-merge verb at all — Approve and MergeCAS are the only write verbs, and MergeCAS issues an immediate PUT /merge?sha=, with a repo-wide grep for merge_when / auto_merge / when_pipeline over internal/ + cmd/ returning nothing outside AutoMergeEligible, a doctor-report field that D-134's own note confirms is never consulted for arming; (3) Thread.Resolved is read ONLY inside internal/forge's thread-idempotence logic, never by internal/core — so thread resolution is not evidence and cannot move a decision. Measured end-to-end through runRun against fakeGitLab, replaying the walkthrough's own narrative (require-review unproven → REVIEW): run 1 → exit=0 threads=1 notesPosted=1 approvals=0 merges=0, decision=REVIEW arm=false → 2 forge operation(s) written; the contributor then resolves every thread; run 2 → exit=0 threads=1 notesPosted=1 notesUpdated=1 approvals=0 merges=0, decision REVIEW, unchanged. The MR never merges. Rerun idempotence holds (no duplicate thread; the summary comment is updated in place). Direction of the error, stated plainly because it differs from D-134's: this one OVER-states automation. Its risk direction is fail-SAFE — no unintended write can follow from it, which is the opposite of D-134/ARM-01, where the docs under-stated what gets written to other people's merge requests. It is nonetheless false, not merely optimistic: the described mechanism is absent from the codebase, and the failure mode is an operator or contributor waiting indefinitely for a merge that will never arrive, with no diagnostic anywhere telling them why. Severity, corrected from this row's first draft (review finding D135-CAL): same defect CLASS as D-134 — a false claim about tool behaviour in documentation that ships with the release — but NOT tag-blocking on the same reasoning, which the first draft asserted and which overstated it. D-134/ARM-01 blocked the tag because its false claim ran in the unsafe direction on the primary surface: the docs under-stated what gets written to other people's merge requests. This one runs the other way — it over-states automation, and it fails loudly and recoverably (someone waits for a merge that does not come). Standing alone it is P2. Fixing it in this lane was still right, on operator override: it was known, it was in a file already open for exactly this reason, and shipping a defect we had found and chosen not to fix is its own problem. But it did not independently block the tag, and this row no longer claims it did. Corrected: walkthrough Step 6's prose now states what was measured (thread + summary comment, no approve, no merge, nothing armed; resolving satisfies GitLab's C3 merge gate but is not assent evidence; forges do not start a pipeline on resolution, per ADR-0009's own text; the merge happens only on a LATER run that reaches APPROVE on its own inputs, performed by assent immediately and SHA-pinned), and the step's > **Shipped** banner now names the unimplemented arming as an explicit exception — preserving the DOC-09 pin, which requires each ## Step to be followed by a Shipped/Planned banner. A short What this means in practice callout closes the operator loop: an MR parked on a challenge finding does not merge when the last thread is resolved. ADR-0009: annotated, NOT decided. The ADR ships with the docs site — mkdocs.yml's nav carries ADRs: adr/README.md, whose index links every ADR page — so it was, until this lane, a published document specifying a mechanism the tool does not have, with a Status caveat (Accepted (partial: one-shot arming restrictions per ADR-0017 §4; P2-E5)) that does not name the gap. Its Amendment now carries an Implementation status note recording that point 1's deferred forge auto-merge is unimplemented, citing this row, and naming the two honest resolutions as OPEN OPTIONS without picking one: (a) build the deferred arming (GitLab C11 — PUT .../merge with auto_merge=true + sha=), or (b) retract the amendment and re-derive the challenge-resolution story around the immediate merge that exists. The amendment's normative text is untouched and the decision stays open — recording that a specified mechanism is unbuilt is a verifiable statement of fact (AGENTS.md rule 6); choosing what to do about it is the architecture decision, and it is the operator's. The annotation went in the amendment BODY rather than the Status row by choice, not by constraint: probed empirically by appending to Status and re-running hack/docs/truthlag_pins_test.sh, which still reported PASS ADR index: 20 ADR status rows agree with their files — the pin compares only the FIRST whitespace token of the Status cell against the index's status column, so Accepted … stays green however much follows. It is nonetheless the wrong home: Status is the ADR LIFECYCLE field the index renders, an implementation gap is a different axis, and trailing free-text there would never surface on the index page a reader actually scans. Open item for the next architecture pass: ADR-0009's Amendment describes behaviour the tool does not have — see the Implementation status note there, which points back here. Third-surface sweep, done because the fix made the word "armed" load-bearing: `grep -i "merge when
D-136 2026-08-09 Merge commits are skipped STRUCTURALLY by cliff.toml; SUPERSEDES the load-bearing status of D-125's "prefix every merge subject" habit (D-125 otherwise stands in full). cliff.toml sets conventional_commits = false + filter_unconventional = false and ends its parser list with a catch-all { message = ".*", group = "Other" }, so any subject not explicitly skipped renders — merge commits included. Measured on origin/main @ 7513d79: three Merge remote-tracking branch 'origin/main' into lane/… lines in the Unreleased section and three more inside [0.1.0], and because release.yaml renders the GitHub Release body with git-cliff --latest and deliberately no --strip header (D-125/AUD-S02 §6), the unreleased three would have shipped verbatim onto the v0.2.0 Release page. The only defence was a working rule — prefix every merge subject with one of the two cliff-skipped forms — which depends on each integrator remembering and had already failed twice in the unreleased section alone. Fix: one commit parser, first in the list, keyed on the commit SHAPE rather than its text: { field = "merge_commit", pattern = "true", skip = true } (merge_commit is true iff the commit has more than one parent). Chosen over a ^Merge message rule because it holds for a merge whose author wrote any subject at all, and over touching the catch-all because that entry is load-bearing for D-125's drift gate. What of D-125 still stands: the pull_request guard on the CI changelog step (see below), the Keep-a-Changelog headings, the D-124 check: wiring closure, and — still LOAD-BEARING, not belt-and-braces — the rule that a regeneration commit must be subject-prefixed :memo: chore(release): or :wrench: chore(release):, since a regeneration commit is an ordinary single-parent commit, and the rule to regenerate after the last content commit and after any git merge origin/main (a merge changes which commits are in range even though the merge itself no longer renders). What is downgraded to belt-and-braces is only the habit of prefixing MERGE subjects. Premise retired, NOT replaced — see OQ-30. D-125 justified if: github.event_name != 'pull_request' by "the PR merge ref's synthetic merge subject always renders, so no committed CHANGELOG.md can match". That reason is dead: this row's parser skips exactly that commit. A successor reason was drafted for this row — "the merge ref also carries every commit landed on main since the branch forked, so the render is a union the branch's file cannot match" — and then measured, and it measures false (three measurements in OQ-30; the direct counterexample is this lane's own head merged into a main that HAD moved: verify-changelog: ok, zero diff lines). The mechanism it overlooked is that CHANGELOG.md is itself three-way merged, so the merge ref's copy already contains the base's lines; base movement therefore ends in a clean merge that matches, or in a CHANGELOG.md conflict that leaves the PR unmergeable and produces no merge ref at all. So the guard is retained WITHOUT a demonstrated reason, deliberately: removing it is a change to CI gating that needs its own evidence, and this lane's mandate is the changelog rendering, not the gate's placement. Whether a PR-scoped changelog gate is now viable is UNTESTED and recorded as OQ-30, with the three measurements and a fourth found while writing them up (merge DIRECTION decides the render order: merging main INTO a lane reds verify-changelog on ordering alone, while the base-first direction GitHub actually mints matched), so the next person starts from data. The four sites that asserted the dead premise (Taskfile.yml check:, verify.yaml's step, hack/release/README.md, changelog_gate_test.sh §3's fail message) now say only that, and point at OQ-30. Writing a second unmeasured justification into the very commit that corrects the first one is the defect this paragraph exists to avoid. Accepted cost: a merge subject can no longer carry changelog content — the merged branch's own commits still render, so nothing is lost, but an integrator who writes :sparkles: feat(x): … on a merge commit will not see it in the notes. Regeneration consequence: task changelog-write also drops the three merge lines from the already-published [0.1.0] section. Accepted: CHANGELOG.md is regenerated in full from history on every run and has never been append-only, and the published v0.1.0 GitHub Release body is a separate immutable artifact that is unaffected. Proof (both polarities, in the already-wired release-changelog-gate-test): hack/release/changelog_gate_test.sh §6 asserts the release body rendered with release.yaml's OWN extracted arguments carries no merge subject; §7 asserts the same over real history and re-renders with the parser entry deleted (mutation proven to have landed) to show the lines return and that the clean/mutant renders differ by nothing else; §7b builds a sandbox repo and shows git's default Merge branch 'x', the Merge <sha> into <sha> CI shape and a hand-written conventional merge subject are all skipped while five ordinary commits render. Revert: drop the merge_commit parser entry and §6's/§7's assertions — reopens the Release-page leak and restores full dependence on integrator memory.
D-137 2026-08-09 REL-14 — cliff.toml groups by the CONVENTIONAL TYPE after the gitmoji shortcode, not by the emoji; a real hotfix was published under "Other". The parser list matched eight shortcodes (:sparkles: :bug: :memo: :recycle: :white_check_mark: :lock: :wrench: :tada:) and, as alternatives inside the same rules, conventional types anchored at the START of the subject (^fix, ^ci, ^docs, …). This project always writes the shortcode FIRST, so those ^type alternatives could never fire — they were dead regex from the day the file was written — and every subject whose shortcode was outside the eight fell through the .* catch-all into Other. Measured on the rendered v0.2.0 Release body: :ambulance: fix(forge): skip malformed bot markers with a warning instead of bricking reconcile (AUD-S12, REL-06) — a fix an adopter would go looking for under Fixes — sat in Other, next to 18 ci(...) commits, 26 test(...), 6 feat(...), 5 style(...), 4 specs(...), 3 docs(...), 2 refactor(...) and 2 chore(...). Fix: eight new parser entries, placed AFTER the eight shortcode rules and BEFORE the .* catch-all, keyed on ^:[a-z0-9_]+: <type>[(:] — the type the author declared — plus one shortcode alias, :test: → Testing (a typo'd shortcode; no such gitmoji exists, and all 19 uses are tests, including one whose type field is the equally typo'd render(...)). Placement is deliberate and narrow: putting the type tier FIRST would re-file large parts of the eight mapped shortcodes too; placing it after means it only sorts what the catch-all was already catching. Keyed on type, never on the emoji, because the emoji is the unreliable half — :lipstick: fix(provider): … is a fix, not a UI change, and :art: is used for both style(…) and refactor(…); a mapping by emoji dictionary would have mis-filed both. The inventory was derived from git log --format=%s over the FULL history (30 distinct shortcodes, 22 of them unmapped), not from a sample, and every unmapped-shortcode subject in this repo turned out to declare a conventional type, which is what makes type-keying total rather than lucky. Judgment calls, stated because a wrong group is worse than Other: (a) specs(…) → Documentation — spec authoring under openspec/ is a written artifact, the same class the existing `:memo:
D-138 2026-08-09 The three reliability P1s of the 2026-08-09 audit (RELI-01/02/03) are DEFERRED past v0.2.0, deliberately and with the deferral recorded (AGENTS.md rule 6). All three are pre-existing at v0.1.0; nothing in v0.2.0 introduced or worsened any of them, verified against 1d8aa60 (origin/main at audit time). Holding the tag would delay two fail-open fixes and a P0 in order to fix defects that are already live. The machine direction holds in all three: no probed path merges anything unevaluated — MergeCAS re-checks all three pins atomically, arming stays default-deny (internal/forge/precondition.go), and the .assent/** self-edit guard still yields BLOCK with zero forge writes. What fails is the human signal channel, which is why they are P1 and not P0. RELI-01 — clean exit-0 runs leave a stale APPROVE banner, and D-130's compensating control does NOT apply here. cmd/assent/run.go emits the DecisionRecord at step 8 and only then enters the step-9 switch, whose two GUARD branches (reservedSelfEditBlock, untrustedExecutionContext) skip forge.Reconcile entirely — including the summary upsert. So run 1 APPROVEs and posts ✅ Decision: APPROVE; run 2, after a .assent/** edit is added, decides BLOCK, exits 0, and leaves that note byte-identical. openspec/specs/p5-e5-provider-host/spec.md REQ-E5-S08-03 accepts a stale banner on the abort path precisely because "a REVIEW rerun upserts that same summary to REVIEW and adds an unresolved discussion" — the discussion being what holds the merge under only_allow_merge_if_all_discussions_are_resolved. On the GUARD-1 self-edit path no thread is posted at all, so nothing holds the merge and no later run repairs the banner: the compensating control the spec relies on is absent exactly where it is needed. Reachability is ordinary CI cadence, and it is deliberately triggerable at the cost of two pushes — D-042's own threat model rendering as a green tick. Its fix is out of release scope BY CONSTRUCTION, not by preference: openspec/specs/p5-aud-audit-remediation/spec.md pins "the decision is BLOCK with zero forge writes (GUARD-1 dominance over the gap-degrade)", so upserting a summary on that path REOPENS a frozen acceptance criterion and needs an openspec change proposal first (see OQ-31). RELI-02 — a duplicated summary comment is UNRECOVERABLE WITHOUT A HUMAN. reconcileThread has both repairDuplicates and a step-9 rescan; reconcileSummary (internal/forge/forge.go) has neither — it is a bare UpsertComment. No branch of Reconcile can remove a second summary note, so once two exist the wrong one can sit at decision=APPROVE forever. Not a corner case: PreconditionFromCapabilities seeds DuplicatePrevention to unserialized-best-effort because per-MR resource_group serialization is never probed, i.e. the race is the project's declared default posture. First item of v0.2.1. RELI-03 — the standing bot approval has no retraction and the cited mitigation is never probed. reconcileApproveMerge records Approve before MergeCAS; on CAS failure in the TOCTOU window the approval is already written and forge.Forge has no Unapprove verb. The code's own safety argument named the forge's remove-approvals-on-push setting — and reset_approvals_on_push appears in ZERO Go files: nothing reads it, probeCapabilities does not fetch it, assent doctor cannot report it. spike-secure-setup.md D11 specified refuse-to-arm when it is off and C19 specified doctor verification; neither was built, and the comment's deferral pointer named P4-E1-S10 — a slice that SHIPPED (D-041), so the concern was never picked up. The comment is corrected in this lane (internal/forge/forge.go, text only, no behaviour change) because an asserted-and-unbuilt mitigation TERMINATES THE SEARCH — it is how this survived two prior audits. RELI-03 needs no open question: D11 already decided it; it is unbuilt, not undecided. Also recorded here, same shape — the ADR-0019 duplicate_prevention: MUST is unmet. The value is computed and typed through to PreconditionReport and then never printed: emitDoctorReport emits only the arm verdict and refusal reasons, so no assent doctor output contains the string. The safe-default half DOES hold (nothing can claim single-writer-serialized without the mechanism). Judgment call: annotate, do not emit. Emitting is ~3 lines, but it is one instance of audit ARCH-11 — doctor computes a typed capability report and prints essentially none of it — so emitting this one field would half-close ARCH-11 and leave the report inconsistent with itself, and it is a user-visible CLI output change, which does not belong in a docs-truth lane before a tag. ADR-0019 now carries the unmet-MUST annotation and points here; the emission belongs to the v0.2.1 ARCH-11 slice with its own tests. Not deferred silently: all three plus ARCH-11 are named in the v0.2.1 wave. Revert: git revert this lane — restores the false RELI-03 comment and changes no behaviour, since the lane changes none.
D-139 2026-08-09 The security lens's SEC-01/SEC-04/SEC-05 are KNOWN and DEFERRED to v0.2.1; the tag proceeds, but the release must not claim --checkout is now safe. All three are pre-existing at v0.1.0 and none was introduced by v0.2.0 (verified at 1d8aa60). Reach on SEC-01 is bounded and that is why it does not block: no Go non-test code constructs --checkout, no CI template, example, hack script or e2e passes it, and neither README.md nor docs/usage/walkthrough.md mentions it at all — the adoption path this project actually teaches is checkout-less. The fix is also monotone-safer to ship after the tag, since the P0 already merged in this release was likewise --checkout-only. SEC-01 — the --checkout tree is never bound to the evaluated SHA. With --checkout the local tree is the sole authority for the judged bytes and the changed-file set (D-077), while pins.sourceSha/targetSha and the compare-and-swap come from the forge; nothing compares the two. Measured: cmd/assent/checkout.go contains zero occurrences of SHA/Sha, and run.go has no step relating the tree to a commit. Reproduced by the lens: forge head a destructive shrink, local checkout a benign grow → APPROVE, approvals=1 merges=1. ADR-0015 §2 promises every write re-verifies what it acts on; the guard re-verifies that the metadata has not moved, never that the judged bytes are the bytes at that SHA. Condition attached and DISCHARGED IN THIS LANE: this release hardens --checkout extensively (D-133 refuses any symlink anywhere) and docs/usage/cli.md already named "run without --checkout" as remedy #1, so the page read as though the flag had been made sound. A Known limitation: the checkout is not bound to the evaluated commit section now says otherwise, framed as a property of how the checkout is CONSTRUCTED (build head/ from the MR head SHA; cancel superseded pipelines) and claiming no mitigation on assent's part — auto_cancel_redundant_pipelines is a project setting the tool never probes, and citing an unread setting as a control is the exact pattern this audit found three times. Named fix for v0.2.1: bind the checkout to the evaluated SHA, or cross-check the local changed-file set against the already-fetched snapshot.ChangedFiles and fold divergence opaque. SEC-04 — protected-config verification is a substring test. internal/forge/gitlab/snapshot.go sets caps.ProtectedPipelineExternal = strings.Contains(proj.CIConfigPath, "@"), while ADR-0015 §4 calls protected config THE load-bearing adoption prerequisite and says doctor refuses to arm when it cannot verify it. Reproduced: an in-repo, author-editable .ci/pipeline@v2.yml arms and merges. Named fix: replace the substring test with a structural parse — require a non-empty suffix after @ that contains /. SEC-05 — absent provenance is read as trusted. snapshot.go computes ForkMR: mrResp.SourceProjectID != 0 && mrResp.SourceProjectID != mrResp.ProjectID, so an absent or zero source_project_id yields ForkMR=false and the fork guard never engages; reproduced to approvals=1 merges=1. Named fix: treat absent or zero source_project_id as fork (untrusted) — unknown provenance must fail toward advisory-only. The composition worth not losing, recorded AS A HYPOTHESIS, not as measured: SEC-05 × SEC-03 would let a fork MR reach the write path and then leave a standing bot approval on a fork MR, converting ADR-0015 §8's "CI on fork / untrusted-contributor MR → advisory-only, no writes" into "we can leave an approval on a fork". There is no evidence real GitLab ever omits source_project_id; the composition is untested and is written down so the v0.2.1 SEC-05 fix is understood as closing more than a provenance nicety. Correction carried from the lens: its SEC-08 was WITHDRAWN as non-novel — D-130 already documents the host-declaration continue-on-any-error as a knowingly-deferred sibling. It is not a new finding and must not be written up as one.
D-140 2026-08-10 E10 (GitHub adapter + Actions entrypoint) is UNLOCKED by direct operator instruction; ADR-0021 governs the seam and openspec/specs/p5-e10-github-forge/spec.md decomposes it into 19 stories (S00, the addressing & representation model, was added after an adversarial review found two P0 representation defects). E10 was Locked under D-012 ("unlocks with a named consumer"), and that lock was reaffirmed twice — D-017 named the consumer and still said "E10/E13 stay locked", D-019 restated "GitHub + remote packs locked". No prior decision authorized it, so the operator's instruction this session is a NEW unlock event, recorded here BEFORE any spec text or code exists (AGENTS.md rule 6). What this authorizes: the GitHub forge adapter, the multi-adapter seam work it depends on, and forge selection in run/doctor. What it does NOT authorize: E13 remote packs (still Locked under D-012 — the "named consumer" reasoning is untouched for that tier), E12 serve, E14 CRD, or any third forge / plugin-forge protocol (ADR-0021 Option D, rejected — no named consumer). Scope of v1 GitHub: behavioural parity for the GATE, not device-for-device parity (dossier §3, OQ-7/OQ-18 — required-conversation-resolution carries acknowledgement, REQUEST_CHANGES reserved for block); the three known deltas (review dismissal, auto-merge revoke, merge queue) are modelled as capabilities, and where GitHub cannot prove what the gate needs the adapter reports the gap and NEVER arms — the same shape as GitLab free tier. Why a seam epic and not just an adapter: the 2026-08-09 audit's ARCH-18/ARCH-19 found docs/planning/design-notes/e10-forge-port-lift.md under-scopes the epic, and that the conformance suite cannot be run by a second adapter because all 1,155 lines live in _test.go files Go cannot import (the package totals 1,166 including the non-test doc.go; the ~1,166 figure in the source design note was corrected on 2026-08-10) — so a GitHub adapter written today would be developed against no executable contract and D-084's github-deferred catalog rows would be unflippable by construction. The original ARCH-18/ARCH-19 finding text is NOT in the repo (only the one-line summary at agent-context/PROJECT-AUDIT-2026-08-09.md:412 survives); the two design buckets were therefore RE-DERIVED as (A) no neutral capability model — the GitHub dossier §4 names eleven flags the port needs while probeCapabilities reads three project fields and capabilityGap is computed in GitLab terms — and (B) no port-level transport/auth policy — GitHub thread resolution is GraphQL-only and needs PAT-vs-App auth, while bounded reads/pagination caps (AUD-S10) and idempotent-GET retry/deadlines (AUD-S11) live inside the GitLab client. Both are recorded as a re-derivation, not as a citation. OPEN SUB-QUESTION (operator): whether the Actions entrypoint (E10-S16) stays in scope — later-phases.md titles the epic "GitHub adapter + Actions entrypoint", but the action is packaging on top of an adapter and is the one story whose absence leaves everything else useful; default is to keep it last and independently droppable. Consequence to watch (E10-S04): forcing every capability to be stated explicitly may turn a GitLab arming path that passes today into an honest capability gap — a user-visible behaviour change that gets its OWN decision row and changelog entry, never absorbed silently into "E10 refactor". Revert: re-lock E10 in later-phases.md, delete the spec directory; nothing here is published API (forge.RunPort is internal/), so no compatibility window applies. ✅ OPEN SUB-QUESTION CLOSED (operator, 2026-08-10): option (a) — the Actions entrypoint (E10-S16) STAYS in E10's scope, kept last and independently droppable; later-phases.md titles the epic "GitHub adapter + Actions entrypoint", so cutting it would have needed the plan text corrected. E10-S16 is no longer operator-gated.
D-141 2026-08-10 E11 (Rego complex-rule backend) implementation is UNLOCKED by direct operator instruction; openspec/specs/p5-e11-rego-backend/spec.md decomposes it into 13 stories under ADR-0002 v2. E11's CONTRACT was already unlocked by D-017; what was gated was IMPLEMENTATION, twice: "after Phase 4" (satisfied — the Phase-4 adoption gate closed with D-042) and, per D-017, evidence-based per rule ("each ported rule tries CEL first, the backend is built when a concrete rule demonstrably exceeds the tier-1 ceiling"). This row records the operator lifting that per-rule evidence gate. What it does NOT waive: the DESIGN need the gate was protecting — E11-S01 still requires a written tier-1 ceiling document with concrete rules per shape (multi-pass, cross-manifest, set-difference, graph-relationship), and any shape found CEL-expressible is struck from scope. What it does NOT authorize: WASM or gRPC predicate backends (still Locked under D-012 — this unlocks Rego only); domain-aware joins and in-process Go rule plugins (D-017 DECLINED both permanently — not deferred); giving Rego any control over aggregation, effects, or points (ADR-0002 v2 boundary); any EvaluationInput change. Two constraints found during design that shape the epic: (1) E11 is the first epic whose DoD is git diff schemas/ != 0. P3-E1-S02's backend-neutrality guarantee ("no field naming a predicate backend anywhere in the schema") applies to EvaluationInput and HOLDS — no decision contract changes — but schemas/policy/v1alpha1/merge-policy.schema.json defines the predicate leaf as additionalProperties:false, required:["cel"], so a rego: leaf IS a policy-schema change. API_STABILITY.md:19 permits exactly this within v1alpha1 as an announced additive change with an openspec change and no apiVersion bump; the change is backward-compatible and deliberately forward-INcompatible (an older binary rejects a rego: leaf by strict-decode, which is the correct direction — it must not silently ignore a rule it cannot evaluate). A reviewer applying the previous epics' git diff schemas/ == 0 habit will flag the correct change as a violation; E11-S02-04 scopes the drift guard rather than deleting it. (2) The reflexive safety measure violates AGENTS.md rule 7. Rego ships time.now_ns(), rand.intn(), and http.send(), all of which are denied structurally by the D-013 capability sandbox (E11-S04, compile-time failure, golden allowlist so an OPA upgrade cannot widen it unnoticed) — but bounding evaluation with a WALL-CLOCK TIMEOUT would itself be a rule-7 violation, making the same policy over the same ChangeSet decide differently on a slow runner. E11-S06 therefore requires a machine-independent evaluation budget, and exceeding it must be a PROCESS ERROR that can never be a policy outcome; "timeout → BLOCK" is explicitly rejected as machine-dependent while merely LOOKING fail-closed. Also fixed by the spec: zero violations NEVER proves a required obligation (later-phases.md's explicit polarity rule; E11-S07-02 tests the failing polarity). 🔴 BLOCKING OPEN SUB-QUESTION (operator) — adopting OPA narrows rule 7's MECHANISM, and both existing purity gates would miss it. Verified during the design session: internal/core/purity_test.go flags only each guarded file's OWN imports (math/rand, crypto/rand, net, net/*) and selectors (os.Getenv, time.Now), and .golangci.yml's pure-tree depguard is list-mode: lax, deny-only, over DIRECT imports — neither is transitive. A file in internal/core/** importing github.com/open-policy-agent/opa/rego therefore passes both gates GREEN while transitively linking net/http (OPA ships the http.send builtin), defeating the net deny that encodes D-123 / rule 7 invisibly. S04's capability sandbox makes http.send uncallable FROM POLICY — the real threat — but the guarantee's nature changes from "the network stack is not linked into the decision path" (structural, greppable) to "linked but unreachable from policy" (behavioural, resting on a capability file). That is a hard-rule change and cannot be made by a story. Options: (d1) accept the narrowing with an ADR-0011/rule-7 amendment plus a transitive go list -deps purity check allowlisting exactly the OPA path (RECOMMENDED); (d2) keep the guarded tree OPA-free by injecting an evaluator from cmd/assent — honest, but it moves part of the decision path outside the tree rule 7 guards; (d3) drop OPA (a hand-rolled evaluator would be far worse; rejected unless d1 and d2 are). E11-S04 is blocked on this answer (it decides the evaluator's package and its gate); S01–S03 are unblocked. Separately on supply chain: OPA is a large dependency with a large transitive tree on a project shipping cosign/SLSA provenance, govulncheck, and Scorecard — recommended default is accept-and-pin, with S03 recording the binary-size delta. Revert: re-assert the D-017 per-rule evidence gate, delete the spec directory, leave the # locked: D-012 quarantine marker in place. ✅ PARTIAL ANSWER (operator, 2026-08-10): github.com/open-policy-agent/opa is ACCEPTED as a dependency and pinned — this closes the supply-chain half of judgment call (d) and rejects (d3) drop OPA. 🔴 The MECHANISM half is still OPEN and still blocks E11-S03 and E11-S04: (d1) accept the rule-7 narrowing explicitly (capability-enforced, plus a transitive purity guard) vs (d2) keep the guarded tree OPA-free behind an injected interface. These are not the same question and "accept and pin" does not settle it — (d2) also accepts and pins OPA; it differs on where the evaluator lives and which gate enforces rule 7. Reading the dependency answer as settling the mechanism would silently choose (d1), i.e. narrow a hard rule by merging a story, which rule 6 forbids. Whichever way it resolves needs an ADR-0011/rule-7 amendment plus its own D-row (REQ-E11-S04-04, the requirement that such an amendment land; the transitive purity check itself is REQ-E11-S04-03). Also corrected here (2026-08-10, independent review of PR #45): the spec originally said E11-S01–S03 were unblocked while (d) was pending. False — E11-S03 is the story that effects the narrowing (REQ-E11-S03-03 adds OPA to go.mod; its Test paths sit in the D-123 guarded tree), and because neither purity gate is transitive it would have landed green. S03 is now explicitly blocked on (d). The epic also gains E11-S00, a deterministic-budget feasibility spike built in a nested throwaway module (no go.work exists, so Go excludes it from the root module's ./...) so the question can be answered without adopting the dependency (d) has not authorised; E11 is therefore 14 stories, S00–S13, and E11-S02 is blocked on S00 because an observe-only rego: leaf is a different published contract from a gating one. ✅ MECHANISM RESOLVED (D-144, 2026-08-16): (d1) — evaluator in internal/core/policy, ADR-0011 amended, transitive purity check required before E11-S03. E11-S03/S04 are no longer blocked.
D-142 2026-08-10 Two public demo repositories are DESIGNED spec-first as openspec/specs/p5-dem-demo-repos/spec.md (15 stories, S00–S14); the answer to "do sample repos already exist" is NO. Operator asked for a testable sample GitLab repo and sample GitHub repo, two designed self-service patterns, and an assessment of user-resolution extensibility. Ground truth established before designing: examples/repos/** are content layouts with no .assent/ tree, examples/packs/** are policy trees with no repo identity, gitlab.com/konrad.heimel/assent-lab is the operator's personal D-042 adoption-proof project (not a showcase), and GitHub presence is zero because E10 is spec-only (D-140). The constraint that shapes the epic: assent cannot run on a GitHub PR today, so the design commits to a two-tier demo contract stated on each repo's front page — tier 1 (git clone && assent test ., forge-independent, no token/network/IdP, deliverable now on both repos via the E6 harness) and tier 2 (live MR/PR: GitLab now, GitHub blocked on E10-S18). Tier 1 is the "everybody can see it" claim. Split by governance shape, not by tool — otherwise two repos demonstrate the same three archetypes twice: repo 1 assent-demo-platform (GitLab) = request a platform resource (kafka-topic, kafka-acl, argocd-application), distinguished by being referential — an ACL names a topic and a principal, an App names a project/namespace/source, so the decision cannot be made from the changed file alone, which is exactly what builtin/resource-owner (E5-S08, REF-GAP-1) and builtin/repo-file (E5-S07) exist for and what no shipped example exercises end to end; repo 2 assent-demo-terraform (GitHub) = change infrastructure (tf-module-instance raw HCL, tf-vars, tf-backend deliberately ungoverned→REVIEW per D-063), distinguished by magnitude/blast radius plus the opaque-change fallback. Repo 2 also becomes E10-S18's live adoption target, so the epic needing a real GitHub repository gets one instead of minting a throwaway. Judgment calls: (a) demo trees live in-tree under examples/demo/<repo>/ and are mirrored out — authoring only in the public repos puts them outside task check and an ungated example rots; (b) platform→GitLab / terraform→GitHub is idiom plus the S18 target, and repo 2's README states the pending live tier in its first screenful; (c) no Keycloak/Entra/LDAP builtins (see OQ-32 rationale); (d) 🔴 OPERATOR — creating public repos under the PlatformRelay org is outward-facing and NOT covered by AGENTS.md rule 2's push grant to PlatformRelay/assent, so DEM-S13 is operator-gated and S01–S12 are unblocked by it; (f) DEM-S05's cross-manifest rules double as the E11-S01 tier-1 ceiling probe D-141 requires (cross-manifest is one of its four named shapes), feeding evidence rather than hypotheticals — and DEM-S05 does not wait for E11. On user-resolution extensibility, the verified verdict is: the seam is genuinely good and extending it needs NO core change and NO fork — the wire contract is frozen and published (schemas/provider/v1alpha1/{request,response}.schema.json, additionalProperties:false, fail-closed state machine with resolved ⇒ value+expiresAt encoded in-schema), API_STABILITY.md:20 binds protocol majors, and extension is a two-file repo-side change read from the PROTECTED TARGET REF (.assent/config.yaml + .assent/providers/<name>.json, per cmd/assent/provider_host.go:61,81-88), so an MR author cannot redefine their own fact semantics. But four gaps stand between that and "easily extendable", and the spec states all four rather than demoing around them: G1 (P1) — find examples -type d -name providers returns NOTHING; all three shipped packs declare providers: with no host declaration, so provider_host.go:83-87 silently continues and every shipped example's provider resolves to nothing, with the required path documented in no doc (DEM-S01 fixes examples + docs + a both-polarity gate); G2 (P1, architectural → OQ-32) — CallHTTP sets only Content-Type, the repo-side provider schema is additionalProperties:false over {type,url,failure}, and ScrubEnv/ScrubArgv refuse (?i)TOKEN|SECRET names even when explicitly configured, so no provider can call Entra ID or Keycloak directly — both need a bearer token; the only working shape is a broker holding the IdP credential itself, which is defensible (ADR-0015 §7 intact, Spike C's hostile-provider isolation proof preserved) but undocumented and narrows what docs/vision.md:67 promises; G3 (P2) — exec providers force adopters to recompute a sha256: pin per release, so the reference provider is HTTP; G4 — ADR-0004 §1 listed OIDC/Keycloak/LDAP builtins that never shipped, and the epic recommends never shipping them: each is an unbounded auth surface entering the decision path's dependency tree for no capability HTTP lacks, and a published contract plus a copyable reference implementation is the stronger answer because it works for the adopter whose IdP nobody has heard of (docs/architecture/c4-context.md:19 is already the only place telling this truth). The demo therefore ships a four-layer ladder (L0 fixtures → L1 repo-file/resource-owner, no network → L2 forge-groups → L3 your IdP broker) with a runnable contrib/providers/idp-groups/ reference carrying Entra and Keycloak adapters, promoted from hack/spikes/provider/toy.go; the L2→L3 step is two files changed, zero lines of assent rebuilt. No new ADR proposed (examples/docs/contrib only) — unless OQ-32 resolves to adding a credential channel, which would need its own. 🔴 SECOND FINDING, more consequential than the GitHub gap and recorded here because rule 6 forbids deciding it silently: (class, environment) binding routing is NOT WIRED, so a multi-class demo repo fails closed in BOTH tiers on BOTH forges. Found by reading the code against the design. cmd/assent/run.go:493 selectBinding fails closed on any RulesetBinding with >1 binding — its own comment says the Config class-matcher "is not wired in this lane"; cmd/assent/test.go:366 selectBindingForTest collapses a multi-binding document to its STRICTEST binding (D-060, fail-safe direction) but fails closed when two bindings differ in class, packs, or require[]. Both demo repos have three classes with different require[], so as designed they fail closed on assent run AND assent test. Why nobody noticed: D-060 itself recorded the shipped packs' dev/prod split as "empirically decision-neutral for the corpus" — every shipped example is single-class with a decision-neutral environment split, so the collapse is invisible and the matcher is never missed. The demo is the first artifact needing real routing. Consequence: the epic gains DEM-S00 ahead of everything ([engine-grade · maintainer LGTM] — it changes decision routing at the cmd/assent seam). The matcher is cheap — policy.Config already carries Environments/Classes as NamedMatch with Match PathMatch (internal/core/policy/policy.go:196-228), populated in every example pack, and internal/glob.Match already implements */** — so internal/core stays byte-unchanged. 🔴 CORRECTION (2026-08-10, independent review of PR #47): this row originally said the data "verified to reach the call sites — no loader work". That was FALSE at both call sites and the correction changes S00's size. On assent run, selectBinding is run.go:219 but the Config load is run.go:224-237 and is guarded by if cfg.config != "" — Config is loaded after selection and only when --config is supplied, so S00 must move the load ahead of selection and decide the --config-absent behaviour explicitly (it must fail closed, never fall back to a collapse or to binding zero). On assent test there is no Config at all: test.go:67 uses catalogue.LoadFromDir, and internal/catalogue/catalogue.go:124-127 states Config is "deliberately absent; a later story that needs config-derived fields adds it then" — Input is {Packs, Bindings}. S00 is that later story, so extending catalogue.Input is a deliberate E6 contract change, not wiring. Config.Classes has zero production readers today — it is precisely parsed-and-discarded. This is why tier 1 is the risk: an implementer who wires only run.go leaves assent test — the epic's primary deliverable — failing closed while the story reads done. S00 preserves fail-closed where routing is genuinely ambiguous (two matching classes, or a (class, environment) pair with no covering binding — both refuse, never guess), keeps compare.go:407 fenced as D-060 left it, and DELETES the selectBindingForTest collapse rather than leaving it as a fallback (two live paths is how a fail-closed guarantee is lost quietly). internal/core byte-unchanged, git diff schemas/ == 0. Also newly visible: the epic's clearest demonstration — the identical diff that APPROVEs in dev and REVIEWs in prod — is impossible in either tier today, which is why it becomes S00's sharpest acceptance test. Third, smaller correction folded in: D-063 covers the unmatched whole-file DELETE only (aggregate.unmatchedDelete); an unmatched EDIT is governed by ADR-0008 §1's implicit unclassified class that no vouch rule may match (correction, 2026-08-10, independent review of PR #47: the original text of this row cited a non-existent "ADR-0008 §27" — ADR-0008 has §1–§4 plus two amendments and no §27. The substance was right and the pointer was fabricated: the implicit class is §1 (internal/core/classify/classify.go:18-20) and the no-vouch-rule-may-match guarantee is enforced by classify.ValidateRouting / ErrReservedClassRouting at classify.go:127-145, whose own error message cites the ADR-0008 amendment + ADR-0015 §1). Two further corrections from the same review, recorded because they narrow what this row promises: (i) repo 1's headline differentiator is demonstrable only at tier 2. The evaluation unit is ONE file — assent run takes a single --subject file:<path> (cmd/assent/run.go:266) and diffs it alone (:289), and adoptertest.Case is singular — so the referential / cross-manifest claim above (an ACL naming a topic deleted in the same changeset) cannot be shown at tier 1 at all. It moves to DEM-S14, which is [infra-gated · operator], via builtin/repo-file's merged-result checkout. (ii) the four-layer ladder's L1 rung does not run at tier 1 either: cmd/assent/test_provider_fence_test.go:69-80 fails the build if assent test touches the live provider host, so tier-1 facts come solely from adoptertest.MapFacts — a reader at tier 1 sees a fixture literal, not ownership resolved from repo files. Tier 1 therefore remains an honest and complete demonstration of L0, and the "everybody can see it" claim stands only at that rung., and run.go:487-492 records that routing as unwired too — so DEM-S10 must DETERMINE the unmatched-edit behaviour by running the code rather than assert it, and anything other than a refusal or REVIEW is a finding to log, not a demo feature. Revert: delete openspec/specs/p5-dem-demo-repos/, this row, and OQ-32. Note the revert is not free of consequence — DEM-S00 describes a real routing gap that outlives this epic; reverting the design does not close it, and D-060 remains its standing record.
D-143 2026-08-15 Complex examples/tests/docs are a dedicated epic P5-EX, not an implementation of P5-DEM. Operator asked for better and more complex in-tree examples, adopter tests, and documentation (multi-field nested resources; YAML + JSON + HCL + tfvars). The E1 adapters already exist; the thin layer is packs/fixtures/docs. Options: (A) implement P5-DEM after adding AUD2-S06-style Test:/Verify:/Level: annotations to DEM REQs — rejected: DEM is public org demo repos + engine-grade (class, environment) routing (DEM-S00) + provider brokers + operator-gated publish (DEM-S13), a different goal, and DEM today has 0 REQ annotations so "green" is undefined; (B) dedicated P5-EX epic (openspec/specs/p5-ex-complex-examples/spec.md) that extends examples/packs/, examples/repos/ layouts, examples/archetypes/, assent test fixtures, and product docs/ and closes REF-EX C1–C8 — chosen; (C) docs-only — rejected: untested README claims are the AUD-S06 class of defect. Chosen B. EX does not re-spec DEM, does not invent a parallel example system, does not duplicate E1 adapters, does not start E10/E11/SEC-SC/AUD2, does not wire DEM-S00, does not raise COVERAGE_MIN. Packs stay single-class. HCL story is honest: structured literal tfvars + .tf blocks opaque→REVIEW (TestHCLStructuralGuardsFailSafe; no new parser). task dogfood-examples is wired into task check and shares discovery with verify.yaml (steal DEM-S12 wiring, not demo-repo scope). Revert: delete openspec/specs/p5-ex-complex-examples/, remove the P5-EX table from openspec/specs/backlog.md and the P5-EX paragraph from openspec/specs/later-phases.md, restore the REF-EX row to OPEN; this row remains as a superseded decision if a later D-row replaces it.
D-144 2026-08-16 D-141's blocking sub-question (d) is RESOLVED: (d1) — accept the rule-7 narrowing explicitly, guarded by a transitive purity check. The Rego evaluator lives in internal/core/policy. ADR-0011 is amended: hard rule 7's guarantee for that package changes from "the network stack is not linked into the decision path" (structural) to "linked but unreachable from policy" (behavioural, capability-enforced) — E11-S04's compile-time capability sandbox denies http.send/time.now_ns/rand.intn with a golden allowlist an OPA upgrade cannot widen unnoticed. Rejected (d2): injecting the evaluator from cmd/assent keeps internal/core OPA-free by letter but moves live decision-path code outside the tree rule 7 guards — a weaker guarantee dressed as a stronger one. New requirement, closes a hole that exists independent of this decision: internal/core/purity_test.go and .golangci.yml's pure-tree depguard are both non-transitive today — a file importing opa/rego passes both green while transitively linking net/http. A transitive go list -deps purity check, allowlisting exactly the OPA import path, is required before E11-S03 lands (REQ-E11-S04-03; this D-row itself fulfils REQ-E11-S04-04, the requirement for an ADR amendment plus a D-row before E11-S05 — see ADR-0011 Amendment 4). Unblocks E11-S03 and E11-S04. Revert: re-assert D-017's per-rule evidence gate; the transitive purity check should be kept regardless — it is a strict improvement over today's non-transitive gates.
D-145 2026-08-16 OQ-29 is RESOLVED: (a) — implement the gate. PolicyProfile.spec.writes: false becomes runtime-enforced, not just documented. cmd/assent/run.go will load the covering profile on the run path and refuse forge.Reconcile when Result.WriteAllowed is false. The write switch at run.go:419-433 already has two zero-write arms (reservedSelfEditBlock, untrustedExecutionContext); this adds a third. Chosen over (b) retract-the-invariant because the claim is already published (docs/architecture/policy-profiles.md, 200-OK, search-indexed) and shipped in v0.1.0/v0.2.0 — retracting a shipped safety guarantee is worse than making it true — and over (c) accept-and-annotate because the gap is a small, contained addition to an existing switch, not an architectural change. Stopgap, same day as this row: docs/architecture/policy-profiles.md gets an explicit "not yet enforced, tracked by this row" annotation until the code lane lands, so the page stops asserting a guarantee nothing holds. Implementation is its OWN independently-reviewed lane (decision-path adjacent) — not landed by this row. Verification target: assent run against a writes: false covering profile performs zero forge writes, mirroring the shape already pinned by run_self_vouch_test.go:27-43. Revert: strike the annotation, leave the gap; would need a fresh D-row citing new information.
D-146 2026-08-16 OQ-31 is RESOLVED: (a) — "zero forge writes" on the GUARD-1 self-edit BLOCK path stays absolute; the BLOCK is surfaced via a required CI job status, not a forge write. The DecisionRecord is already emitted at run.go:409, before the guard switch, so the BLOCK is on stdout/--emit today — only a forge-visible channel was missing. A required CI job reads the emitted record and fails the check when the decision is BLOCK-via-self-edit, giving the human reviewer a visible signal without touching the exit-code contract docs/usage/cli.md documents for wrapper scripts. Chosen over (b)/(c) because both permit a write and thereby reopen the frozen acceptance criterion in openspec/specs/p5-aud-audit-remediation/spec.md ("BLOCK with zero forge writes, GUARD-1 dominance"), requiring an openspec change proposal before any code; (a) reopens nothing. Implementation (the CI job) is a separate lane, before the RELI-01 fix lands. Revert: leave the reviewer without a forge-visible BLOCK signal (today's status quo).
D-147 2026-08-16 OQ-32 is RESOLVED: (b) — add a credential channel to the provider transport, via a host-side secret resolver; an ADR amending ADR-0015 §7 is required before any code. Operator constraint: secrets must work across GitLab CI variables, GitHub Actions secrets, and the hosted variant; Vault injection is one pattern among several, most adopters will set plain CI variables. This is the higher-cost option — it reopens both a frozen schema and the trust boundary Spike C's hostile-provider isolation proof rests on — chosen over (a) bless-the-broker-pattern (docs-only, zero cost, but does not give adopters a direct-credential option) because the operator wants flexibility broker-only cannot offer. Design constraint carried into the ADR, not yet written: the credential must be host-side only — resolved by process env (CI vars), file path (Vault/CSI-injected secrets), or a hosted secret store — while the repo-side config.schema.json (attacker-editable, the same tree the MR under evaluation can change) gains only an opaque reference name the host must have pre-allowlisted. An MR must never be able to pair a credential with a URL of its own choosing; a repo-side literal secret-ref would make the provider block self-editing an exfiltration path. ScrubEnv/ScrubArgv's TOKEN|SECRET stripping for the exec tier is restated explicitly in the ADR, not silently loosened. Next: write the ADR (host-side resolver design, pluggable sources), decompose into openspec stories, then the provider-author guide (DEM-S02) states the answer. Revert: fall back to (a), broker-only, document and stop.
D-148 2026-08-16 OQ-30 is RESOLVED: (b) — keep the pull_request-scoped CHANGELOG-drift guard skipped, and record the mechanism that was actually found rather than leave the row open. D-125's original reason (synthetic merge-ref subject breaks the render) died when D-136 started skipping merge commits; four live measurements then failed to reproduce a successor failure. The real mechanism, found while measuring: merge DIRECTION decides the outcome. A clean textual auto-merge whose line order differs from git-cliff's topological traversal order reds with no author error and no author fix available — reproduced by merging origin/main into a lane (lane as first parent). GitHub always mints PR merge refs base-first, the direction that matched cleanly on both measured cases, but that is two measurements, not a proof that the ordering hazard can never surface on pull_request. Chosen over (a) enable-on-pull_request because the false-positive mechanism above is real and unfixable by the author if it recurs, and behaviour on pull_request_target, fork PRs, and post-force-push re-mints is untested; over (c) keep-permanently-on-cost-grounds because it would discard genuine evidence worth keeping. The guard stays fail-safe regardless — it still runs in task check and on push-to-main (.github/workflows/verify.yaml:103). Revert: re-open if a pull_request-scope red is ever observed that is a false positive under this mechanism — that would upgrade this from "recorded reason" to "confirmed hazard" and argues for (a) with a compensating check instead.
D-149 2026-08-16 OQ-1 / D-031's apiVersion rename is SUSPENDED pending the operator's in-flight brokered purchase of assent.dev. The shipped schemas already carry apiVersion: assent.dev/v1alpha1 and $id: https://assent.dev/schemas/...; D-028 recorded assent.dev as third-party-owned, and D-031 ratified Path A (register an owned domain) as the fix. The operator has since initiated a brokered transfer of assent.dev itself via GoDaddy — not yet closed. If it completes, D-028's premise is void and the already-shipped string is correct: zero adopter migration. Registering and cutting to a fallback domain now would risk a breaking apiVersion change for v0.1.0/v0.2.0 adopters that turns out to have been unnecessary. Named fallback, only if the transfer fails: assent.platformrelay.dev/v1alpha1 — a portfolio-wide domain (platformrelay.dev) rather than a fresh assent-* registration, so the group string is settled once for every future PlatformRelay repo rather than re-litigated per project. Checkpoint: re-visit this row at the next handover if the transfer has neither closed nor visibly failed, so it cannot rot silently. Revert: register the fallback domain immediately and cut over — abandons the free outcome without waiting for its resolution.
D-150 2026-08-16 SONAR-GO-CX-TEST is RESOLVED: adopt a checked-in sonar-project.properties excluding **/*_test.go from the S3776 cognitive-complexity rule, accepting the loss of SonarCloud Automatic Analysis. 66 CODE_SMELL issues, concentrated in 34 table-driven Go test files, against a Quality Gate that is otherwise OK (0 bugs/vulns/hotspots) — high cognitive complexity is the idiom in table-driven Go tests, not a defect. Chosen over the UI-only Won't-Fix route (keeps Automatic Analysis, but the suppression is not reviewable in-tree and does not survive a project re-import) and over refactoring the 34 files (churn on passing tests for a non-gating metric). Accepted cost: a scanner step must be added to CI and maintained going forward — Automatic Analysis stops once a sonar-project.properties is present. Revert: delete the properties file to restore Automatic Analysis; the 66 smells return.
D-151 2026-08-16 OpenSSF Best Practices project creation is RESOLVED: (a) — the operator will create the bestpractices.dev project for PlatformRelay/assent. Unblocks SEC-SC-S02 (evidence page + README badge, once passing); SEC-SC-S01 (fuzzing) is not blocked by this and can proceed independently. Operator action only — requires a personal account at bestpractices.dev, not automatable in-tree. Current CII score is 0, which drags the OpenSSF Scorecard (~7); the repo already satisfies most criteria (pinned CodeQL, Dependabot, secret scanning, signed releases, SLSA provenance). Revert: leave CII at 0 indefinitely — SEC-SC-S01 is unaffected either way.
D-152 2026-08-18 The 2026-08-18 audit's "Next (risk reduction)" wave is decomposed as its own epic P5-AUD2 (openspec/specs/p5-aud2-audit-remediation/spec.md), five stories, spec-first. Context: agent-context/PROJECT-AUDIT-2026-08-18.md closed both P1 conditions the same day and v0.3.0 shipped, leaving a named but untracked wave — exec-transport trio (REL-01/02/07), REL-03 ErrNotFound discrimination, SEC-03 cosign identity pin, TEST-02 mutant. Options considered: (a) hand-fix them as loose commits (rejected — AGENTS.md rule 4 is spec-first, and REL-01 is now byte-identical across three audits precisely because nothing ever tracked it); (b) append them to the existing P5-AUD epic (rejected — that epic's exit gate is CLOSED and reopening it would make "AUD complete" a moving claim); (c) a new AUD2 epic keyed to this audit (chosen). Three consequences recorded here so they are not re-litigated: (1) AUD2 has no release-condition story — the audit had exactly two P1s and both are already closed; (2) WG-S01 is deliberately excluded — it carries the LGTM governance marker, and GOVERNANCE says such stories are surfaced to the maintainer, which an autonomous loop's decide-and-log does not override; (3) AUD2's exit gate is a task check stage, not a release-exitgate step, because that job is pull_request-skipped (RELSE-08) and wiring the gate there is how AUD-S18's own stale CHECK_STAGES pin survived four merges. A 2026-08-10-keyed AUD2 draft exists only in the local stash leave-aud2-not-this-epic, was never committed, and is superseded; its still-open items (F3/F5/F7) stay Later-wave. Revert: delete the spec + backlog section; the four findings return to the audit report untracked.
D-153 2026-08-19 The cosign signer-identity pin published in SECURITY.md was WRONG, not merely missing from hack/install.sh: the identity regexp is widened to ^https://github\.com/PlatformRelay/[Aa]ssent/ in both files (AUD2-S03 / SEC-03). Substance first: SECURITY.md's "Verify a tagged release" instructions pinned --certificate-identity-regexp '^https://github.com/PlatformRelay/assent/', and an adopter who followed them on v0.2.0 or v0.3.0 got a verification FAILURE on a genuine, correctly signed artifact — the published recipe has been broken for every release since v0.2.0, and the natural reading of that failure is "this release was tampered with". Cause: the repository was renamed PlatformRelay/assent → PlatformRelay/Assent between v0.1.0 and v0.2.0; the keyless signing certificate's Subject Alternative Name carries GitHub's canonical casing; cosign compiles --certificate-identity-regexp as a Go RE2 pattern and matches it case-sensitively. Decoded from the published bundles: v0.3.0 and v0.2.0 sign as https://github.com/PlatformRelay/Assent/.github/workflows/release.yaml@refs/tags/vX.Y.Z, v0.1.0 as https://github.com/PlatformRelay/assent/.github/workflows/release.yaml@refs/heads/main. All three verify under the new value; independently re-confirmed with real cosign against the real v0.3.0 artifact (Verified OK under the new pin, exit 1 "expected SAN value to match regex" under the old). Options considered: (a) ship the pin byte-identical to the published-but-broken value and file the breakage as a follow-up (rejected — it would land a green gate asserting a guarantee that fails closed on this project's own releases, D-124's defect one level up); (b) case-insensitive (?i) (rejected — wider than the defect and it silently accepts casings GitHub never issues); (c) an explicit [Aa] class in both files, dots escaped (chosen). Not widened otherwise: the ^ anchor and the owner/repo scope stand, so another owner, an assent-mirror typosquat, another forge and an unescaped-dot host all still fail. Enforcement, per D-128: one published truth — hack/release/install_cosign_pin_test.sh extracts the pair from hack/install.sh and SECURITY.md, requires exactly one distinct value per file, reddens on drift, and (this is the assertion that would have caught the defect) matches the pin against the three real SANs above, committed as offline fixtures, plus six negatives. Extends D-110 (bundles beside archives) and D-109; the maintainer-path twin at hack/release/verify-artifacts.sh:124 is still unpinned and is tracked in the backlog, not fixed here. Revert: restore '^https://github.com/PlatformRelay/assent/' in both files — which re-breaks verification of v0.2.0 and every later release, so revert only together with a repo rename back to lowercase.
D-154 2026-08-23 Every hack/** script that uses a bash 4+ feature declares a PER-SCRIPT version floor via the shared hack/lib/require-bash.sh, because one of them was a silent local FAIL-OPEN under stock macOS bash 3.2 (BASH32-F01). Substance first: /bin/bash hack/docs/truthlag_pins_test.sh on macOS printed 20 PASS lines, died at its declare -A ex_s09_prefix_hint=(…), never printed its final OK: all truth-lag pins green banner — and exited 0. Its caller task docs-gates, and therefore task check, read that as a green gate, and AGENTS.md rule 4 makes a green local task check a per-commit precondition; CI is ubuntu/bash 5, so nothing merges through this path, which is precisely why it survived. Mechanism, measured on bash 3.2.57: 3.2 has no associative arrays, so under set -u it re-reads declare -A x=([k]=v) as an INDEXED array assignment and evaluates the subscript k arithmetically, hitting "unbound variable"; the shell terminates the script and the exit status is 0. Two theories were disproved en route and are recorded so they are not re-explored: the trap … EXIT status-swallow (3.2 propagates 1 through an EXIT trap correctly) and set -e (the script runs set -uo pipefail). The other two affected scripts fail CLOSED, but only incidentally: hack/release/verify-artifacts.sh:202's literal is EMPTY, so there is no subscript to evaluate and it degrades to declare: -A: invalid option, which its set -e turns into exit 2 (measured with a --dist that reaches line 202 — an unreachable dist exits 1 much earlier); hack/validate-schemas-stock.sh's mapfile degrades to "command not found" and it exits 1. Populating an empty literal or dropping -e silently converts a closed failure into the open one, so "how this construct happens to degrade" is not a guarantee worth depending on. Options considered: (a) one blanket repo-wide floor (rejected — the binding minimum differs per feature: declare -A 4.0, local -n 4.3, mapfile -d '' 4.4; a blanket floor both refuses shells that could run the 4.0 gates and hides which feature actually binds); (b) rewrite the three scripts to be 3.2-compatible (rejected — associative arrays and mapfile are the right tools here, a rewrite is a large risky change to scripts whose correctness IS the product, and it does nothing about the next gate author); (c) copy the existing inline BASH_VERSINFO guard from hack/audit/aud2_exitgate_test.sh:71 into each script (rejected as the only measure — it duplicates the explanation three more times and still leaves the next author to rediscover the hazard); (d) chosen — a shared require_bash <major>[.<minor>] <feature> helper, called with each script's own correct floor (4.0 / 4.0 / 4.4), plus a meta-gate. Enforcement, per D-128, is one place: hack/lint/bash_version_guard_test.sh, wired as task check stage 20 and pinned in hack/audit/exitgate_test.sh's CHECK_STAGES in the same commit. It scans every hack/**/*.sh for command-position bash 4+ constructs, requires a guard on each, requires the declared floor to be at least the feature's own minimum, and — the assertion that makes the rest non-vacuous — requires the scan to still find all four known feature-using files, so a typo in a detection pattern reds instead of quietly matching nothing. Its own negative control mutates real copies of the three scripts with the guard lines stripped and asserts they are flagged. hack/audit/aud2_exitgate_test.sh keeps its inline guard and is accepted by the gate as guarded; adopting the helper there is a follow-up, not this lane. Stated limits: the gate sees only command-position constructs in hack/**/*.sh (not eval, dynamically built command names, case-modification expansions, or scripts outside hack/), and the exit-0 control needs a real bash 3.2 — present on macOS, absent on CI ubuntu, where it SKIPs loudly rather than passing silently. Accepted behaviour change: under 3.2, hack/release/verify-artifacts.sh --help now refuses instead of printing usage. Revert: delete the guard calls — task check then silently stops certifying the docs truth-lag pins for every contributor whose bash is 3.2.
D-155 2026-08-23 E10-S00 — the GitHub addressing & representation model is written (docs/planning/github-addressing-model.md) and ADR-0021 items 5–8 are executed UNAMENDED. (1) Fork-head addressing (P0): the governed subject moves to MR-relative FileAtBase(mr, path) / FileAtHead(mr, path); FileAtRef(project, path, targetRef) survives for ref-addressed decision inputs. Verified against the tree rather than transcribed: exactly two call sites migrate (cmd/assent/run.go:270, :274) and six must not (run.go:203/211/230/249, provider_host.go:82/292). Anchor drift, attributed to the document that carries it: ADR-0021's genuine drifts are two — provider_host.go:275 (the D-130 who-may-approve registry read; actual :292) and the proposed Describe(project, mr) (the tree's method is GetMR). A third stale anchor, provider_host.go:246 refFilePort (actual :263), lives in openspec/specs/p5-e10-github-forge/spec.md:257 — E10-S02's own DoD, where an implementer would have tripped on it — and is fixed in this change. (2) Capability predicates: every dossier §4 flag gets one of two kinds — P probed (named endpoint + field + comparison) or C contract-proven (a constant licensed by a NAMED conformance case; no case ⇒ unknown). Counted against the tree, GitLab probes 3 of 11 honestly, 1 by heuristic, hardcodes 1, and leaves 7 implicitly assumed; unknown ⇒ never arm binds per capability at its consultation point (internal/forge/precondition.go consults 3 today), not "all eleven". BOTH arming-relevant GitHub capabilities are unknown, so v1 GitHub COMMENTS AND DOES NOT GATE — the fail-closed outcome, stated up front. protected-pipeline-source is unknown on both forges (GitLab's strings.Contains(ci_config_path, "@") is a heuristic, not a predicate) → OQ-33, which now poses ADR-0015 §4's own three routes including the largely forge-readable same-repo one; retiring the heuristic is a user-visible GitLab behaviour change owed its own D-row at S04/S09 (judgment call (e)), not minted here. eligible-approval-evidence is unknown → OQ-34: the CODEOWNERS route is legitimate (ADR-0017 §3 names it; the frozen schema enumerates codeowners), but no probeable conjunct decides what full DENOTES — that the adapter-computed eligible set EQUALS the forge's — and an over-permissive matcher would make the DecisionRecord assert an obligation satisfied by an ineligible principal, harm needing no arming. This confirms ADR-0021's Consequences prediction (whose stated reason was dossier §2 step (b), the CODEOWNERS step) and agrees with the dossier's own partial verdict; an earlier draft of this model graded it full, contradicting that spec input, and the wrong answer is kept visible in the doc rather than edited away. (3) Record surface (P0): ADR-0021 item 8 option (ii) confirmed — the multi-capability report is doctor output and arming-refusal reasons only, never the DecisionRecord; git diff schemas/ == 0 holds and NO schema change is proposed. There is no gap-selection rule to invent — the field is reserved for one capability. Stated honestly rather than over-claimed: the allOf forbids capabilityGap only when mergeResultDigest is pinned, and run.go:368 sets the gap unconditionally, so the channel is open in 100% of runs today and the schema clause is a latent guard until an adapter pins a real digest (S03/S07); what closes the comma-join loophole meanwhile is a conformance case, not the schema. The audit-trail cost is real but pre-existing: aggregate.Result.CapabilityGaps already never reaches the record. (4) Status → sentinel: forge.ErrUnauthorized lifts to the port at S02; a GitHub 404 is absence only when a CONTENT-SCOPE probe (a sibling /contents read at the same ref) returned 200 — deliberately NOT a repo-metadata probe, since a fine-grained PAT with metadata: read and no contents: read gets 200 on GET /repos/{o}/{r} and 404 on every file, which would re-open the P0 through the front door; a 404 on branch-protection/ruleset reads without permissions.admin is unknown (never absent), and rate-limited 403s are transport errors — extending the AUD2-S02/REL-03 discrimination one layer down into the adapter that mints the sentinel. Twelve conformance case IDs are minted, each fail-closed case paired with a positive control (E10-S12's rule applied early), and they are wired into S01/S07/S14's DoD in the epic spec rather than left in a planning doc nothing reads; internal/forge/conformance/catalog.yaml remains S01's to edit. ADR-0021 stays Proposed — accepting it is the maintainer's call, and E10-S00 is [LGTM].
D-156 2026-08-23 The tier-1 (CEL) ceiling is recorded (docs/planning/rego-tier-ceiling.md, E11-S01 / REQ-E11-S01-01+02) and it NARROWS E11: two of the four named shapes are struck outright and a third in part. Substance first: of multi-pass / cross-manifest / set-difference / graph-relationship (openspec/specs/later-phases.md:284), cross-manifest and set-difference are struck, the "named intermediate" half of multi-pass is struck, and only fold/aggregate-over-a-collection and in-input graph reasoning survive as justification for a second backend. Measured against the surface this repo actually binds — internal/core/aggregate/evaluate.go newEvalEnv, eleven frozen predicate-scope variables, zero extension libraries — reproduced in a nested throwaway module pinned to the repo's cel-go v0.31.0 (root go.mod/go.sum byte-unchanged; nothing added to the dependency graph, which judgment call (d) has not authorised). What exceeds tier 1: (1) folds. sum, reduce, math.* and lists.* are all undeclared reference; size() is the only aggregate in the surface, so a rule counting matched changes is expressible and a rule summing a numeric delta across them is not. (2) Graph reasoning — the number of nested iteration levels in a CEL expression is syntactic and cannot be made data-dependent, so traversal depth is fixed by the expression text and unbounded reachability has no spelling (amended 2026-09-03, D-166 — this clause said "the iteration count of a CEL expression cannot be made data-dependent, so depth is syntactic"; withdrawn for the reason given at the second amendment site below); a bounded k-hop form is writable and answers a fixed-depth question, not the rule (detailed below, with the refutations of two sharper claims this row previously made). What is struck, with the CEL that does the job: registry membership — string(new) in facts.registry.topics.value over a cardinality: set fact, already shipped green at examples/packs/service-catalog/.assent/packs/catalog/rules/ownership.yaml:21; keyed attribute lookup — either a purpose-built provider (builtin/resource-owner, exercised in both polarities by examples/archetypes/referenced-resource-ownership/) or facts.<p>.<n>.value[key].field, which compiles and is lint-clean because internal/lint/facts_ref.go's D-051 shape check permits arbitrary navigation past .value and only forbids indexing facts itself; set difference — oldEntry.acls.filter(a, !(a in entry.acls)).size() == 0; named intermediates — no cel.bind, but re-deriving the sub-expression inside each leaf is semantically identical and compiles. The sharpest finding is a distinction, not a rule: same-changeset cross-file reasoning fails for input availability, not expressiveness — the evaluation unit is one file (change.ChangeSet is documented "for one file", internal/change/diff.go:129; assent run strips one --subject file:<path>, cmd/assent/run.go:266, and diffs it alone, :293) and REQ-E11-S05-01 pins the Rego module to the identical EvaluationInput with the tier fenced to "declared data, no I/O". A Rego module therefore fails that rule identically, so it is struck from E11's justification too — the fix is an input decision, which E11's non-goals fence, not a backend decision. Corroborating: the one committed illustration of the escape hatch, examples/policies/rego/bounded_change.rego, is entirely tier-1 expressible — both violations rules are per-change predicates and examples/policies/declarative/bounded-change.yaml is the same rule already in the envelope. The strongest single piece of evidence, and the one that sizes the epic, is the graph shape. On the assent run path document-mode walkNode emits a Change only where two scalars differ — a sequence on either side makes the whole ChangeSet opaque → REVIEW before any rule runs (internal/change/diff.go) — and collection-mode DiffEntries is reached only from internal/adoptertest, so old/new/entry/oldEntry and every changes[i].old/.new bind scalars. That does not starve a graph rule, because a flat set fact still carries structure: edges: {type: string, cardinality: set} returning ["orders|billing","billing|ledger"] is a fully declarable, in-contract adjacency inside EvaluationInput today — the declaration is legal (schemas/provider/v1alpha1/response.schema.json), fact.value carries no JSON-Schema type constraint and ResolveFactsChecked cross-checks the declaration not the value (internal/provider/resolve.go), outputs are operator-authored (internal/provider/config.go), the http transport is live on the plain run path with no --checkout (cmd/assent/provider_host.go providerCallFor), and a set fact binds as a CEL list. Tier-1 CEL nonetheless cannot answer the actual question over it: CEL has no recursion, fixpoint, fold or user-defined function, so unbounded reachability has no spelling (transitiveClosure → undeclared reference; the four comprehension macros iterate one level and cannot call themselves). Stated precisely, because two drafts of this record overreached and both were caught by independent review: a bounded k-hop check is expressible and does not need the string decomposition CEL lacks — over a finite in-input candidate set (a nodes set fact, the same construction as edges), decode is replaceable by encode-and-compare: nodes.value.exists(m, nodes.value.exists(n, (string(new)+"|"+m) in edges.value && (m+"|"+n) in edges.value && (n+"|"+string(new)) in edges.value)) compiles and evaluates under the real cel.CostLimit(1_000_000) to true/true/true/false over the fixture above, and nodes.value.filter(m, (string(new)+"|"+m) in edges.value) recovers an edge's far end as [billing]. Both primitives (+ on strings, in) were already in this record's own function census, so the claim that decoding was a second independent barrier was internally inconsistent as well as false. The real ceiling is k, and a third draft got its cost wrong too — it claimed O(|N|^(k-1)·|E|) "exhausts the cost budget on any real graph". Wrong complexity class and wrong conclusion, because the census had omitted the surface's only value binder, [expr].all(v, …): binding each BFS frontier once per level makes cost additive across levels, and measured under the real cel.CostLimit(1_000_000) a ring |N|=50, deg 5 costs roughly 10% of budget at k=10, 25% at k=20 and 70% at k=50 — roughly linear in k, and at k=50 ≥ |N| CEL is not approximating at all, it decides reachability exactly. The practical k collapses around |N|≈200 (k=10 at roughly 45% of budget, k=20 exceeds). Precision withdrawn 2026-09-03 (D-166): this row published those costs as 89,551 / 235,297 / 686,317 and "in 69% of budget" / "k=10 at 46% of budget", but the generating BFS-frontier expression was never written down anywhere and the probe was deliberately not committed, so no reader can re-derive or falsify a figure. They are rounded here to the nearest 5% of the 1_000_000 budget (8.96→10, 23.53→25, 68.63→70, 46.26→45 — the rule is stated so the granularity is uniform and checkable), a precision the argument uses — cost is roughly linear in k rather than exponential, and a k ≥ |N| check fits at |N|=50 and not at |N|=200 — and docs/planning/rego-tier-ceiling.md §5 carries the full disclosure and what would close it. Recorded because a reader sizing this epic is entitled to ask "would CEL suffice for our graph?" and the previous sentence foreclosed that question with an assertion measurement refutes; it is not offered as a second reason. What carries the verdict is one property, now measured rather than recalled: the number of nested iteration levels in a tier-1 CEL expression is syntactic and cannot be made data-dependent (amended 2026-09-03, D-166 — this row said "the iteration count of a tier-1 CEL expression cannot be made data-dependent", which read alone is false: a comprehension over an input collection costs |N|, and |N| is data; what is data-independent is the number of nested levels, not the count within a level). reduce, transformList, transformMap, two-var all(i,x,…), range/lists.range and cel.bind are all undeclared reference; for is a reserved identifier; there is no fold, self-reference or generator; so depth is a syntactic property, hard-capped by cel-go's parser recursion limit of 250 (expression recursion limit exceeded: 250 at depth 260, depth 200 still compiles). That a fixed k really is an approximation when the data outruns it is measured too: on a 4-cycle the k=3 form evaluates false and the k=4 form true. Rego answers the unbounded question with graph.reachable — pure and deterministic — and that single gap is the whole justification. An input that is in contract and available today, over which CEL can only approximate and Rego decides, is exactly the per-rule evidence D-017's gate demanded — so E11 has TWO unconditional justifications (folds and graphs), neither waiting on any open question. Deliverability caveat, stated because this row makes the same distinction for sub-shape B1: no provider in the corpus ships an encoded adjacency today — that one can follows from the provider contract, the same inference used to strike B1, not from a shipped example. Options considered: (a) record all four shapes as exceeding, the reading D-141's unlock invites (rejected — three are demonstrably expressible and an unfalsifiable ceiling claim is the defect this story exists to prevent); (b) strike set-difference conditionally, so that a narrowing answer to OQ-35 would return it to the ceiling (rejected on the second pass — both resolutions strike it: extending the entry binding makes CEL express it, narrowing makes it an input-availability failure that REQ-E11-S05-01 hands to Rego unchanged, so a conditional would have left a downstream story waiting on an answer that cannot change the verdict); (c) strike cross-manifest and set-difference unconditionally, strike half of multi-pass, keep folds and in-input graphs both unconditionally (chosen). Two rejected drafts are recorded because the same failure produced both, with the sign flipped, and only independent review caught either. Draft 1 made the graph shape contingent on OQ-35/OQ-36 on the premise that "an adjacency needs a mapping-valued fact" — it does not; a set of encoded strings carries one inside the frozen declaration — which came from conflating a provider-supplied catalog (sub-shapes B1/B2, struck here as working and shipped) with B3, which is specifically same-changeset cross-file diffs. It would have advised the operator to defer a 14-story epic on a premise that does not hold. Draft 2 replaced that with a second "independent reason" — that CEL cannot decode an edge, killing even a bounded two-hop check — refuted by execution (the encode-and-compare leaf above). Draft 3 replaced that with the exponential-cost claim above — refuted by measurement. The common cause across all three is one habit: reaching for a second independent reason when one sufficient reason was already in hand, and then applying an asymmetric evidentiary standard to protect it — accepting awkward-but-working spellings when they struck a shape (A2's re-derivation, B2's dynamic index) and rejecting them when they would have narrowed one this record wanted to keep. The verdict was right in all four drafts; the supporting argument was wrong in three. It now rests on one measured property and no supporting argument at all, which is the form it should have taken first. The correction to A2 runs the same way: the value binder [expr].all(v, …) removes the evaluation-cost half of A2's residual, so A2 is struck more firmly than the first draft struck it. Binds later stories: S05 must not be widened to carry cross-manifest data; S07's violation shape must support a fold result and a path/cycle witness, not a cross-manifest reference; S12 must correct ADR-0002's rego bullet, which called the tier an "escape hatch for cross-entry checks" — inaccurate for the shipped input contract (done 2026-09-03 ahead of S12, D-166 / E11-R01; ADR-0002 Amendment 1 records the withdrawn sentence); S11 must label the unquarantined example a shape illustration, never evidence of need. Binds E11-S04, whose denylist is not yet written: graph.reachable MUST be allowed, with split alongside it — both pure and deterministic. graph.reachable is the one that carries the justification (it closes the graph at any depth); split is a convenience for rebuilding the adjacency. A capability set drafted from "deny anything unfamiliar" would strike out the epic's own strongest justification. Recorded in the S04 section of the epic spec, not only S01's. That floor is held by REVIEW, not by a gate, and this row says so rather than inventing an enforcement path: REQ-E11-S04-02's committed golden detects a change to the allowed set (so the sandbox cannot silently widen) and cannot detect an omission — a golden written without graph.reachable stays green forever. It says nothing about judgment call (d), since which builtins are callable is orthogonal to where the evaluator lives. Two residuals raised, neither decided, neither gating any verdict here, and neither the escalated rule-7 (d1)/(d2) question: OQ-35 (the entry/oldEntry binding asymmetry — a silent assent test / assent run divergence; set-difference is struck either way and the graph shape needs no entry tree) and OQ-36 (the frozen provider declaration has no object/map type, yet the authoring surface and builtin/repo-file together permit a mapping-valued fact). Also flagged, not edited (out of lane): REQ-DEM-S05-04 calls the provider-visible tree the "merged-result checkout"; the code reads <root>/head, documented at cmd/assent/checkout.go:44 as "the MERGE-REQUEST HEAD". This lane writes no Go, adds no dependency and touches no schema. Revert: delete docs/planning/rego-tier-ceiling.md and the D-156 narrowing text from the E11 spec — which restores an epic justified by four shapes, three of which this record shows are not real.
D-157 2026-08-23 "Reaches pull requests" is now a SHARED, structural check (hack/lib/pr_reach.sh), the three PR-visible text gates CROSS-PIN each other's steps, and the fixed-string step check is replaced by an anchored run:-command match (GATES3). Three residuals, each demonstrated by an independent reviewer on an earlier lane and each deliberately left unfixed there, all living in the same three files — hack/lint/workflow_pins_test.sh, hack/audit/aud2_exitgate_test.sh, hack/examples/dogfood_wiring_test.sh. (1) The substantive one. Two of the three answered "am I reachable on a PR?" with grep -qE '^[[:space:]]+pull_request:' over the workflow's on: block; a reviewer added paths: ['internal/**'] under the trigger and measured rc=0 — the trigger is present, the grep is satisfied, and the gate is disarmed for every PR that touches only Taskfile.yml or .github/workflows/**, which is exactly the class of PR these gates exist to grade. types: [closed] is the same defect by another route, and the grep's [[:space:]]+ is indent-blind, so a pull_request: nested under workflow_call.inputs satisfied it too. The third gate did not answer the question at all — workflow_pins_test.sh asserted its step was present and undisarmed and never that the workflow carrying it runs on a PR; this is new coverage there, not a strengthening, and the brief that commissioned this lane assumed otherwise. Decision on meaning: a workflow "reaches PRs" when it has a top-level pull_request: trigger — a key of on: itself, at 2-space indent — whose sub-block carries only keys from GitHub's five (types, branches, branches-ignore, paths, paths-ignore; a sixth is refused, not guessed at), with no paths:/paths-ignore:, a types: list that is absent or a superset of GitHub's defaults (opened, synchronize, reopened), and no branch filter excluding main — each read at whatever indent it is written at, and with its block sequence read whether the items are indented under the key or written FLUSH with it, because the sub-block extraction is already scoped to the trigger and an indent pin bought no isolation the scoping did not already provide; branch and type names are compared as WHOLE tokens split on YAML's own separators, so main-next and release/main are not main — and when the target job carries neither a job-level if: nor a job-level needs: (a dependency that skips on PRs skips its dependents, defeating all three gates at once while every other assertion stays green), and the gate's own step carries no if:/continue-on-error: in either the mapping-key or the sequence-item position ( - if: is the step's first key and is a sequence item). Deliberately still accepted, stated so a reviewer does not read the narrowing as wider than it is: on: pull_request and on: [push, pull_request] (the inline forms cannot carry a filter, so they are strictly safer than the block form), the quoted "on": key, a types: superset, branches: listing the literal main, branches-ignore: sparing main, block-scalar `run:
D-158 2026-08-23 CI-TOOLCHAIN — verify was red on every PR from a golangci-lint/Go skew; the remedy is to bump GOLANGCI_LINT_VERSION v2.12.2 → v2.13.1 and KEEP go-version: stable. Symptom: PR #85, a six-file Markdown diff with zero Go bytes, failed verify with /opt/hostedtoolcache/go/1.27.0/x64/src/crypto/internal/randutil/randutil.go:11:2: could not import math/rand/v2 (… method must have no type parameters) (typecheck) — the failing path is inside the Go toolchain's own source tree, so no author change can fix it and re-running cannot clear it. Analyze (go), Analyze (actions) and CodeQL all passed on the same run: the tree compiles clean under Go 1.27, only the linter chokes. Mechanism: golangci-lint typechecks the standard library with the go/types it was compiled with, not the go on PATH. GitHub's stable rolled 1.26 → 1.27.0 between this repo's last green main run (2026-08-19) and 2026-08-23; the v2.12.2 release binary is built with go1.26.x and cannot read the 1.27 stdlib. Reproduced locally, not reasoned (Go 1.27.0 SDK on PATH, GOTOOLCHAIN=local, whole tree): v2.12.2 → panic in pkg/goanalysis/runner_loadingpackage.go:335 inside go/types.(*Checker).Files, exit 2; official v2.13.1 asset (built with go1.27.0) → 0 issues, exit 0; golangci-lint config verify → exit 0, so the v2 config schema in .golangci.yml needs no migration; v2.13.1 also → 0 issues against local Go 1.26.6, so the bump is safe for contributors who have not moved; and go install …@v2.13.1 under Go 1.27.0 → 0 issues, which is the release-exitgate path (that job builds the binary rather than using the action, and if: github.event_name != 'pull_request' / RELSE-08 means no PR ever exercises it). Diagnosis heuristic worth keeping: golangci-lint's go.mod carries the comment "the minimum Go version must always be latest-1", so its go directive is a machine-readable "supports Go 1.N+1" signal — v2.12.2 says go 1.25.0 (supports ≤1.26), v2.13.1 says go 1.26.0 (supports ≤1.27), and upstream commit 42a0530 in v2.13.0 is literally "go1.27 support". Options considered. (A) bump the linter — CHOSEN: treats the root cause, is a one-line diff because the version is single-sourced at workflow scope (D-128), fixes BOTH exposed sites (the golangci-lint-action in verify and the go install in release-exitgate) at once, and the risk it carried — new findings from a newer linter blocking every merge, since golangci-lint runs inside task lint inside task check — was measured at zero on the whole tree under both Go versions. (B) pin go-version: 1.26.x — REJECTED as the primary: it needs two pins (verify.yaml:56 and :171), leaves the class of bug live, undercuts the stated reason stable is there (# govulncheck needs it, with setup-go exporting GOTOOLCHAIN=local so a pinned toolchain can fail to go run a tool whose module requires a newer Go — unverified, and moot under (A)), and trades a loud recurrence for silent indefinite drift onto an unsupported Go in a repo whose posture is govulncheck + cosign + SLSA + Scorecard. (C) both — REJECTED as gold-plating: (A) alone is measured green, and a second simultaneous remedy would make a still-red PR ambiguous. Per-file sweep of go-version: stable, stated rather than silently skipped: verify.yaml:56 and :171 were the only exposed sites and are fixed by the env bump without touching either line; schemas.yml:57 (runs go test + ajv, no linter), vulncheck.yaml:30 (govulncheck only — stable is actively desirable there) and codeql.yaml:45 (CodeQL, which passed on 1.27.0) carry no golangci-lint and are NOT exposed; release.yaml uses go-version-file: go.mod and is unaffected. hack/lint/workflow_pins_test.sh asserts TASK_VERSION, action SHA pins and step wiring but not go-version or GOLANGCI_LINT_VERSION; it passes on this change. Revert is (B), not bare v2.12.2 — reverting the env line alone restores a known-red state: revert = GOLANGCI_LINT_VERSION back to v2.12.2 and pin verify.yaml:56 + :171 to go-version: 1.26.x. Residual risk, stated plainly: when stable rolls to Go 1.28, v2.13.1 breaks identically. The repo finds out because verify reds on every PR including zero-Go diffs — loud and immediate, with the signature recorded in verify.yaml's env comment and the fix being one env line. Nothing detects it earlier: dependabot's github-actions ecosystem updates uses: refs, not env: literals (backlog CI-TOOLCHAIN-F01). Accepted on purpose over (B)'s failure mode. Note also the pre-existing local/CI linter skew — Taskfile.yml's lint task is bare golangci-lint run ./... against whatever binary a contributor has installed, so this pin has never governed local task check; deliberately not changed here (that file was owned by a concurrent lane).
D-159 2026-08-23 ORPHAN — release-verify-test was a gate invoked by nothing (D-124 again), and it is wired into task check in its three OFFLINE modes, not its default all. Finding: Taskfile.yml defined release-verify-test — the only test of hack/release/verify-artifacts.sh — and no caller existed anywhere: not check:, not CHECK_STAGES in hack/audit/exitgate_test.sh, not .github/workflows/**, not the push-only release-exitgate. That script is not incidental: SECURITY.md:61 publishes it as the procedure release consumers run to check artifact checksums, the stamped assent version and cosign signatures, and D-153/AUD2-F01 had just proved that published verification instructions rot silently. Measured before fixing, as the lane required: the gate was green, so this is a wiring defect only — nothing was being papered over. Why not the all default. verify_test.sh with no argument adds test_snapshot_pass, which shells out to task release-snapshot: go install github.com/goreleaser/goreleaser/v2@… on any machine without it (network, in a gate that runs before every commit), a before: hooks: go mod tidy that rewrites go.mod/go.sum in the middle of task check, --clean on the developer's dist/, and a five-target cross-compile (10s here with warm caches and goreleaser already installed; minutes cold). A flaky or network-bound stage is worse than a missing one, so check: runs negative, cosign-skip-when-absent and readme — REQ-E9-S12-02/03/04, all offline, together well under a second. Consequence, stated precisely: REQ-E9-S12-01 (the real snapshot→verify round trip) is not gated per-commit in task check; it runs in push-only CI via hack/release/exitgate_test.sh:33-34 (the release-exitgate job, if: github.event_name != 'pull_request') — automated but not PR-visible, i.e. the RELSE-08 blind spot. Those two lines are literally task release-snapshot then task release-verify, which is the Verify: string the spec gives the requirement (openspec/specs/p5-e9-distribution/spec.md:441-442), so nothing here needs a new gate built — an earlier draft of this row claimed the requirement was covered by no automation at all, which was false, and acting on it would have meant a future lane duplicating exitgate_test.sh:33-34. What this lane declines to do is move that L1 round trip into a per-commit gate: it cannot be, for the network/go mod tidy/--clean reasons above. Narrowing the RELSE-08 exposure for this requirement is a separate question from D-159. Pinning. CHECK_STAGES goes 20 → 21 in the same commit as the check: line (the AUD-S18/RELSE-08 lockstep), and STAGE_BODY_PINS pins the body per mode rather than by the bare script path — a single bash hack/release/verify_test.sh pin would stay green if two modes were dropped and if the stage were reverted to the unrunnable-in-check default. Three mutation controls prove it: deleting the check: line reds check_check_wiring by stage name, gutting one of the three modes reds check_stage_bodies while the other two still run, and the same deletion applied to the real tree reds the real gate at rc=1. Also fixed, minimally, because wiring made it matter: verify_test.sh wrote its captured stdout/stderr to fixed /tmp/verify-{tamper,nosig}.{out,err} and then grepped them; harmless while nothing ran it, but this repo runs several lane worktrees whose task check invocations overlap, and a concurrent run truncating /tmp/verify-nosig.err at open would fail the other run's skip.*cosign grep spuriously. Scratch files now live in each run's own mktemp -d (invisible to verify-artifacts.sh, which globs only *.tar.gz/*.zip) and are removed by the existing RETURN trap.
D-160 2026-08-23 UNIV-COSIGN — the cosign signer-pin gate asserted an EXISTENTIAL property where the guarantee it publishes is UNIVERSAL, so it certified nothing about a file's second cosign verify-blob; hack/release/install_cosign_pin_test.sh now grades EVERY invocation in ALL THREE published files, statically and at runtime. Substance first, and measured before anything was written: a scratch copy of each graded file with a second, unpinned cosign verify-blob spliced IN-BODY next to its real invocation (the splice shape matters — see UC-04 below) ran the gate to exit 0 — hack/install.sh rc=0, SECURITY.md rc=0, hack/release/verify-artifacts.sh rc=0, the last of these being the shape a reviewer had already demonstrated on an earlier lane. Every individual assertion in that gate was sound. has_flag grepped the whole folded extraction, so a pinned sibling satisfied it; one_value's sort -u collapsed two agreeing values to one, so the D-153 drift comparison saw a consistent file; section 0 printed the extracted invocation count and asserted nothing about it; and 4b/5d's stub-log checks were positive-only greps, i.e. the same existential shape at runtime, where a second unpinned call leaves the pinned call's log line intact. The conjunction of sound existential assertions is still an existential assertion. This is the AUD2-S05 quorum defect one layer out: not a wrong assertion, a wrong quantifier, and it predates both AUD2-S03 and AUD2-F01. SECURITY.md was worse than the other two — it fed only the drift comparison and had no invocation-level grading at all, so an unpinned recipe published to adopters was invisible to the gate that exists to police the recipe. Property chosen: "every cosign verify-blob invocation in every graded file carries --certificate-oidc-issuer, --certificate-identity-regexp and --bundle, EACH WITH SECURITY.md's published value". The value half is not decoration: --certificate-identity-regexp '' carries the flag and matches every Fulcio identity ever issued, and one_value's sort -u does not catch it either, because an empty capture is not a line and the file still reports exactly one distinct value — a presence-only universal check would be the same defect one level down, so each invocation's own extracted value is compared against the published pair (reported WRONG-VALUE[...], distinct from UNPINNED[...]). Options considered: (a) the remedy recorded when the finding was filed — assert the extracted invocation count is exactly 1 per file (REJECTED AS FACTUALLY WRONG — do not implement it in a later lane; it would red main on the day it lands. SECURITY.md legitimately publishes two — one over the archive, one over checksums.txt, which is what covers the SBOMs listed inside it — so that assertion reds main on the day it lands, and the natural repair is to weaken it again); (b) universal quantification over invocations (chosen) — strictly stronger than (a) where (a) is true, and it keeps holding when a file legitimately grows a third correctly-pinned call. Enforcement, per D-128, is one predicate: pin_violations <file> prints one line per problem and nothing when the file is clean, so callers grade its OUTPUT and the identical function is asserted empty against the tree and non-empty against a mutant with no set -e games; assert_all_pinned is the thin failing wrapper; log_unpinned_lines is its runtime twin over the stub-cosign argv log. Classification is fail-CLOSED, which is the part that carries the guarantee. The candidate extractor is deliberately left un-narrowed (every non-comment line mentioning cosign verify-blob, continuations folded), because anything the denominator drops is a hole. A candidate is an invocation when the folded text begins with cosign verify-blob; the ONE waved-through non-invocation shape is a markdown inline-code mention (`cosign verify-blob`, which is how SECURITY.md's capability table names it); on a line carrying NO backticked mention, anything else — foo && cosign verify-blob …, a here-doc line, an un-backticked prose sentence — is reported UNCLASSIFIABLE rather than skipped, because "this gate cannot tell whether that call is pinned" must never read as "that call is fine". That scoping is not cosmetic, and the unscoped version of this sentence was FALSE (R3-01, caught by review on the round that added the honest banner). The exemption tests whether the substring `cosign verify-blob` appears anywhere on the line, not whether the line is prose, and it is tested BEFORE the command-position and multi-token checks. So a line carrying a backticked mention AND one or more LIVE calls is exempted, neither graded nor refused: measured by splicing See `cosign verify-blob`: cosign verify-blob --bundle A.json a && cosign verify-blob --bundle EVIL.json evil into SECURITY.md — occurrence_count = 2 by the definition the banner itself states, pin_violations EMPTY, whole gate rc=0, while the banner reported that line among those "exempted as backticked prose". The static defect is not markdown-specific — splicing a live unpinned call with a trailing backticked mention into hack/install.sh gives candidates=2, invocations=1, pin_violations EMPTY — but the whole-gate green is markdown-specific, and the distinction is stated because a looser sentence here would itself be falsifiable: in that shell script the run reds at §4b for an unrelated RUNTIME reason (the spliced call executes and its bundle is absent), whereas SECURITY.md is a document with no runtime twin, which is why rc=0 survives there. Measured both ways rather than inferred from either. Narrowing the exemption needs quote- and markdown-aware parsing — the machinery that produced UC-01/02/03 — so it is deliberately NOT patched here. It is instead disclosed as residual (1) in the PASS banner, ahead of the variable/eval/fragment spellings, because it is the residual that is actually REACHABLE in these three files while those are not present in any of them. The banner also no longer labels an exempted line "prose": the branch it took is a fact, "prose" was an inference, and R3-01 is the case where that inference is wrong. This is UC-07 doing its job on its own author: the claim was falsifiable, it was checked, and the gate is now incomplete-and-honest rather than complete-and-wrong. Accepted cost, stated rather than discovered later: a correctly pinned invocation written off command position also reds. That is the intended direction of error for a security gate over three files carrying four invocations in one canonical style, and the message names the fix. Anti-vacuity, because a lane whose entire deliverable is "make a gate stricter" is maximally exposed (this repo's #1 review finding is an assertion that cannot fail — D-124, AUD-S18): section 2b runs eight mutants against each of the three files and 2c runs a ninth, and they were chosen for branch coverage of the new predicate rather than for variety of shape — (a) a second unpinned invocation must red, name the file, quote the offending command, and produce exactly two violations so the pinned siblings are provably not flagged; (b) a second correctly pinned invocation must stay green and must raise the extractor's invocation count by one, without which (b)'s green could mean "the extractor never saw it"; (c) an occurrence off command position, appended fully pinned on purpose so the only thing that can redden it is the classifier itself; (d) every invocation deleted → NO-CANDIDATES; (e) the continuation fold broken → FOLD-BROKEN, asserted not to also report UNPINNED, because conflating "the extractor truncated this" with "the pin is missing" sends the next maintainer to the wrong file; (f) every invocation displaced off command position → NO-INVOCATIONS; (g) a second invocation carrying both flags with an empty identity regexp → WRONG-VALUE, the hostile shape a presence-only check waves through; (h) a second invocation carrying both flags with a foreign OIDC issuer → WRONG-VALUE on the issuer half; and 2c strips the backticks from SECURITY.md's prose mention and requires the same row to fail closed, proving the exemption keys on the backticks and is not a blanket bypass. The runtime twin has its own vacuity control (an unpinned line appended to a copy of the stub log must be seen). One real defect was caught by this section during the lane and is recorded because it is a trap for the next author: log_unpinned_lines was first written with awk -v id="identity_re=${sec_identity}", and awk processes escape sequences in a -v assignment, so the pin's github\.com arrived as github.com and matched nothing; the values now reach awk through ENVIRON. Stated limit, same posture as D-154's: the denominator is the literal string cosign verify-blob, so an invocation spelled through a variable ("$COSIGN" verify-blob …), built by eval, or assembled from fragments is invisible to this gate. Widening the trigger to bare verify-blob was considered and rejected — it buys one hypothetical no file uses, and turns any future SECURITY.md sentence saying "verify-blob" outside backticks into a red gate, whose repair would be to loosen the classifier. Nothing outside the gate changed: hack/install.sh, SECURITY.md and hack/release/verify-artifacts.sh are byte-unchanged, and all four of their invocations were already pinned — this row closes a hole in the checker, not in the checked. The stage release-install-cosign-pin-test was already wired into task check and pinned in hack/audit/exitgate_test.sh's CHECK_STAGES, so no wiring changed either. SECOND ROUND — the first version of this fix REPRODUCED the very defect it closes, one level down, and independent review caught it before merge. has_flag grepped the whole FILE; the replacement grepped the whole LINE. Both are existential; only the scope shrank. UC-01: one folded line was graded as at most ONE invocation, so cosign verify-blob <both pins> --bundle a.json a && cosign verify-blob --bundle EVIL.json evil began with cosign verify-blob (not UNCLASSIFIABLE), carried --bundle (not FOLD-BROKEN), carried both flag strings (not UNPINNED) and yielded the published values (not WRONG-VALUE) — green, in same-line, ;-separated and backslash-folded forms, on all three graded files. Worse, the full gate exited 0 for SECURITY.md: the two shell scripts were incidentally caught by the runtime twin (log_unpinned_lines at §5d), but SECURITY.md is a document and has no runtime twin, so the hole was unmitigated precisely on the file this row calls "the copy-paste recipe adopters run by hand" — D-153's exact incident class — while the gate printed "EVERY cosign verify-blob invocation ... is pinned", false as printed. The stated-limit paragraph did not cover it: it enumerated $COSIGN, eval and fragments, not && chaining, and leading && was explicitly handled (mutant (c) → UNCLASSIFIABLE), so the operator had been considered and only one side handled. Closed by grading per OCCURRENCE (occurrence_count + a new MULTI-OCCURRENCE violation that fails closed on any line carrying two calls) rather than per line; counting inside an already-triggered line uses the shorter verify-blob, so a second call spelled cosign verify-blob or "$COSIGN" verify-blob and chained onto a pinned one is caught too, which the literal file-level trigger would miss. UC-02: extract_issuer/extract_identity anchor on a greedy .*, so the LAST value on a line wins — a hostile --certificate-identity-regexp '' placed FIRST was masked by a correct value placed second, the extraction direction favoured the attacker. Closed by flag_value_tokens, which returns EVERY value of every flag on the occurrence; each must equal the published one, so a repeated flag with an empty value first now reds. UC-03 (P2, the erosion path): a CORRECTLY pinned call written with double quotes was refused as WRONG-VALUE ... identity=<empty/unparsable> ... (an empty regexp matches every Fulcio identity) — a true refusal with a false reason, and the repair a maintainer reaches for on that message is widening the extractor. Both quote styles are now accepted (double quotes are correct shell for this value: it contains no $ and no backtick), and a bare value is diagnosed as UNQUOTED-VALUE, a QUOTING defect, never as a wrong or empty one — a gate that misdiagnoses erodes faster than one that refuses. UC-04: this row's "rc=0, rc=0, rc=0" reproduction figure is splice-shape-sensitive and is stated as measured: it holds for a second unpinned call spliced in-body next to the real one. An EOF-appended mutant reds the two shell scripts for an unrelated runtime reason, so the figure should not be read as holding for any second unpinned call. The same sensitivity is why the UC-03 double-quoted mutant reds the two scripts through the full gate (the spliced call actually RUNS and its fixture bundle is absent) while SECURITY.md, having no runtime, is the clean end-to-end proof at rc=0. The classifier's fail-closed posture on off-command-position occurrences was independently adjudicated KEEP, since §2c proves the prose exemption keys on the backticks and cannot be widened silently. The lesson, recorded because it is the whole point of the lane: a fix for an existential-where-universal defect is itself maximally exposed to the same defect at the next scope down — file, then line, then occurrence — and the only thing that caught it was a reviewer building the chained-call mutant that neither the 20-row external matrix nor the 25 in-gate mutants contained. THIRD ROUND (UC-05..UC-09), and the reason this row now reads the way it does. UC-05: occurrence_count compared whitespace-delimited fields to verify-blob, found one, and concluded singularity — when all it had established is that it did not find a second. cosign "verify-blob", cosign 'verify-blob' and cosign verify-blob"" are the same command to the shell, and each hid a chained unpinned call from the count. Measured against the round-2 gate on all three files at whole-gate level: rc=0, rc=0, rc=0 — worse than first reported, because A && B short-circuits and set -e does not fire on a non-final member of an AND-list, so even the two shell scripts' runtime twin missed it. Closed by stripping shell quotes before the field compare, which is a spelling patch, not a terminator, and is labelled as one in the code. A structural terminator was sought and ruled out with evidence: refusing any graded line carrying a command separator would red the REAL files, since hack/install.sh and hack/release/verify-artifacts.sh both legitimately end their genuine invocation with an or-else die tail. No structural terminator exists for this gate, so completeness is not available and must not be claimed. UC-07 is therefore the durable fix, and it is a change of posture rather than of logic. The PASS: banner was printed unconditionally and asserted "EVERY cosign verify-blob OCCURRENCE ... is pinned" plus "a second unpinned call reddens whether it sits on its own line or is chained onto a pinned one" — a universal claim bound to no assertion. Under UC-05 that banner did not merely overstate: on a green run it printed something false, which is strictly worse than printing nothing, and it is this repo's #1 defect class (an assertion that cannot fail) wearing a different hat. The banner now reports OBSERVED counts per file (occurrences found, classified-and-graded, prose-exempt, refused), lists ASSERTED properties each of which is shown to fail on a mutant, and states the NOT ASSERTED residual in the same breath: this gate finds calls by a literal string and counts quote-stripped words, so a call spelled through a variable, eval, or fragments is not seen and not graded, and the counts are what was classified — not a proof that nothing else exists. When a fifth spelling turns up, the gate is then incomplete rather than wrong, and incomplete is recoverable. UC-06: a TRAILING comment mentioning verify-blob fired MULTI-OCCURRENCE: … has 2 verify-blob calls chained on ONE line — fail-closed but false as printed, since cosign_candidates only drops lines whose FIRST non-space character is #. That is the UC-03 misdiagnosis shape recurring inside the UC-01 fix. The message now says TOKENS, names both possible causes, and names the correct repair; excluding trailing comments was rejected as it needs quote-aware parsing, which is the machinery that produced UC-01/02/03. UC-08: shellcheck SC1087 ×2 (error severity) on flag_value_tokens — "$flag[[:space:]]…" parses as an array subscript; ${flag} silences it. Cosmetic: no shellcheck gate exists in this repo. UC-09: two stage-count figures reported during this lane ("25", then "28") were both wrong and neither came from a committed file — they counted task: [ lines in a run LOG, which includes nested task invocations. Measured: the Taskfile check: block has 21 - task: entries and CHECK_STAGES has 21 stage names; they agree, and both files are byte-identical to origin/main. There is no stage-count mismatch and this lane could not have caused one. THE PATTERN, recorded because it is the lane's most transferable output: four P1s, three of them the same scope error at successively finer scopes — file (has_flag), line (round 1), then occurrence-spelling (round 2) — each found by review and none by the author's own mutant matrix, because each matrix was built from the shape the previous round had just learned. The general lesson is not "add another spelling": it is that when a gate's denominator cannot be made provably complete, the gate must report what it classified instead of asserting what exists. That is now enforced where it is hardest to ignore, in the banner the gate prints on every green run. Revert: restore has_flag, drop the per-occurrence grading and the per-invocation value comparison, and restore the two positive-only stub-log greps — which returns the gate to certifying that some cosign call in each file is pinned, a property that is compatible with an unpinned one sitting beside it.
D-161 2026-08-24 E10-S00's backlog.md:557 maintainer-LGTM (GOVERNANCE) marker is SATISFIED: the operator granted LGTM explicitly and authorized the integration of PR #85. Recorded here because the approval was given in an agent session, and an approval that exists only in a transcript is invisible to everyone who later asks whether the marker was honoured — the same defect this lane's own handoff named as a standing lesson. GitHub's reviewDecision on #85 is empty (the maintainer is also the PR author, so GitHub refuses a self-review), which is why this D-row plus a PR comment — not a GitHub approval — is the durable record. Integration mechanism: #85 was 2 ahead / 24 behind with three genuine append-adjacency conflicts (CHANGELOG.md, decisions.md, open-questions.md). Force-push is forbidden (hard rule 2), so the branch was brought up to date by merging main down into the lane and merged with gh pr merge --merge, NOT by a rebase and NOT by a replacement PR — this preserves #85 and, with it, the full review history posted as its comment. Conflicts were resolved as ordered unions and verified by content, not by count (D-152..D-160 each present exactly once; OQ-31..OQ-36 each exactly once; all 11 CHANGELOG entries present); task changelog-write was then a byte no-op, independently corroborating the CHANGELOG union. What the LGTM covers: a docs/spec-only model (zero Go, zero schema bytes) whose one P1 was fixed fail-closed, so v1 GitHub comments and does not gate. What it does NOT cover: the same marker still guards E10-S02/S04, E11-S02/S04/S06/S07, DEM-S00, WG-S01 — each needs its own LGTM; this row is not a blanket waiver. Revert: git revert the merge commit.
D-162 2026-08-25 P5-DEM is re-decomposed spec-first before any DEM story is implemented: all 15 stories (DEM-S00–S14) carry ZERO Verify: annotations, so not one of them states how it would be proven. Operator-ruled 2026-08-25. This is a spec defect, not a per-lane gap: hard rule 4 puts specs before code, and DEM-S14 is the epic exit gate — it has nothing to aggregate if each lane invents its own gate ad hoc when it is picked up. Rejected: annotate lane-by-lane — cheaper per lane, but it defers the question of what the epic proves to the last story, which is precisely where this project has repeatedly found gates asserting a universal property their denominator could not establish (D-160, D-157). Rejected: leave parked — DEM is the adoption/demo epic and DEM-S11 is also E10-S18's live adoption target, so it is on the critical path for E10's own evidence, not optional. Scope: re-decomposition is a spec change under openspec/ only; no DEM code lands until every DEM story has a Verify: annotation. Revert: restore the current 15 rows from git history; the annotations are additive and can be kept regardless.
D-163 2026-08-25 The recurring "dependabot reddens main on the CHANGELOG drift gate" failure is fixed narrowly, at the bot, NOT by enabling the drift gate on pull_request: OQ-30's guard STAYS and D-148 STANDS. Operator-ruled 2026-08-25 after an initial ruling to the contrary was withdrawn on corrected premises. Incident: cd4dac1 + 34e94cf (codeql-action 4.37.7→4.37.8) merged green and reddened main in BOTH verify (step changelog drift gate) and release-exitgate (task check stage 21) — run 32817829705/32817735039; healed by PR #96. Why not enable the gate on PRs, even though D-148's stated mechanism does not survive: OQ-30 measured the merge-direction ordering hazard only in the lane-first-parent direction, while GitHub mints the merge ref base-first — so D-148 is defensible as PRECAUTION, not as mechanism, and this row records that explicitly rather than leaving a dead premise standing. The decisive objection is different and was missed when the question was first put: dependabot cannot run task changelog-write on its own branch, and verify is a REQUIRED status check with strict=true and enforce_admins=true (measured via the branch-protection API). Enabling the gate would therefore convert "main red for ~1h, healed by one regen commit" into "every dependabot PR red on a required check until a human pushes a regen commit to the bot's branch" — worse for the very incident that prompted it — while additionally exposing OQ-30's three still-untested cases (fork PRs, pull_request_target, force-push re-minting the merge ref) on a required check, where a false positive is unfixable by the author. Mechanism sub-choice is deliberately NOT taken here and is parked for the operator in agent-context/INBOX.md: (i) auto-regenerate on dependabot branches needs a write token on a bot-authored PR branch (pull_request_target or a PAT) and races dependabot's own rebase force-pushes; (ii) making the push-to-main gate tolerate bot-only build(deps) drift is a FAIL-OPEN in a gate, the exact species this repo has spent two waves removing, and would need both polarities plus a mutation control before it could be trusted. Until one is chosen, the standing remedy is the status quo: heal main with a :memo: chore(release): regen commit, whose subject cliff.toml:119 skips so it does not add itself. Revert: none needed — this row changes no code; it records why the guard was not deleted.
D-164 2026-08-25 DOC-TRUTH — backlog.md rows E11-S03 and E11-S04 still said "blocked on the operator's rule-7 answer (d1/d2)" nine days after D-144 answered it, and that staleness actively misled a session. D-144 (2026-08-16) resolves D-141's sub-question (d) to (d1) and ends with the words "Unblocks E11-S03 and E11-S04"; the two backlog rows kept their 🔴 marker and their "blocked on the operator" text regardless. Consequence, observed rather than hypothesised: the 2026-08-25 session handoff and the operator's own session-opening message both carried "the rule-7 (d1/d2) question is still parked on you" forward as fact, and the question was nearly put to the operator a second time. Same species as D-157/D-160 — a claim retained after the thing that justified it was withdrawn. Fix: both rows now cite D-144 and carry their real remaining dependency. Generalisation deliberately not made: no gate is added here asserting "every 🔴 row has a live blocker", because that predicate would match row TEXT, and matching text where the property is structural is the defect this project keeps re-introducing (14 P1s across two gate lanes). Revert: restore the two rows from git history.
D-165 2026-08-25 E10-S01 extracts the forge conformance suite into importable Go, and the extraction found that the two SHA-guard cases had NEVER run against GitLab despite their catalog rows claiming forge: gitlab. That second half is a DOC-TRUTH defect in its own right, same species as [D-157], [D-160] and [D-164]: catalog.yaml asserted adapter coverage that did not exist, for two L1 cases on the SHA-guard — the single most safety-relevant property in the suite — and nothing detected it for the entire life of the file, because the only thing checking the catalog compared it against test-function NAMES, which were all present and correct. A row claiming coverage is not evidence of coverage. Scope decisions taken deliberately: (a) conformance.Factory returns a forge.Forge, NOT the composite forge.RunPort that REQ-E10-S01-01 names — RunPort is E10-S02's deliverable and S02 is LGTM-gated, so declaring it in this [autonomous] lane would smuggle an LGTM-gated core-contract change past the backlog.md:557 GOVERNANCE marker; S02 depends on S01 exactly so the port change lands against an executable suite, and the substitution is then a one-line change. (b) Merge/approve/thread write counts are taken by a PORT-LEVEL decorator (portcount.go), not per adapter, because they are not observable at the transport layer for GitLab — its MergeCAS re-reads heads and refuses client-side, so a moved head produces ZERO merge HTTP requests and an httptest harness would report 0 attempts where the port was called once. (c) Cases take pins from Fixture.Pins() instead of literals, because GitLab synthesises its merge-result digest (gitlab.SyntheticDigest) — a literal digest is why those cases could only ever run on the fake. Collapsing that digest is E10-S03. Assertions were STRENGTHENED, never downgraded (REQ-E10-S01-04): the summary-updated-in-place check was guarded behind if ff, ok := f.(*fake.Forge); ok and therefore unfailable on every non-fake backend; merge ATTEMPTS are now distinguished from merges PERFORMED, so "the pre-check refused before any mutation" (0 attempts) is separable from "the atomic CAS refused" (1 attempt), which len(Merges) == 0 alone could not express; and a NotesUpdated() > 0 check was replaced with an exact count after a gate proved no corruption of that value could flip it. Coverage of the catalogued cases went from partial-and-asymmetric to all five on both adapters; the package's passing assertions went from 9 test functions to 103 subtests. The three self-checks are the deliverable as much as the extraction is — observed-execution catalog matching, can-fail-against-sabotage on every adapter, and per-observation load-bearing corruption; the last of these found two real weaknesses in code that had already passed review, which is the recorded lesson that reviewers must EXECUTE rather than read. Foreseeable consequence of THIS commit, recorded here so E10-S02's author does not discover it: the extraction moved ~700 statements from _test.go (uncounted) into production-classified Go whose only exerciser is the three self-check gates, and global coverage now sits at 91.1% against a 91.0% floor. Every future E10 story that adds a conformance case also adds uncovered t.Fatalf assertion branches and will red the D-010/D-128 floor. The fix when it happens is NOT to delete assertions — it is either to extend the load-bearing gate to the new case (which covers those branches as a side effect, and is the reason the floor is met today) or to rule on whether a test-support package belongs in the coverage denominator at all. Logged in agent-context/INBOX.md. Revert: the pre-extraction _test.go bodies are one commit back; the gates are additive and worth keeping regardless.
D-166 2026-09-03 ADR-0002's published claim that the rego tier is an "escape hatch for cross-entry checks" is WITHDRAWN; the tier's justification is folds/aggregates and unbounded graph reachability, and four D-156 review residuals are closed with it. Substance first: the rego bullet of docs/adr/0002-policy-frontends-rego-declarative.md had read, since 2026-07-21, "Escape hatch for cross-entry checks, complex derivations, whole-branch conventions." D-156 (2026-08-23) struck all three clauses and the sentence stayed published anyway. It now states the two shapes D-156 measured — folds/aggregates over an in-input collection (tier 1 has size() and no sum/reduce/math.*/lists.*) and unbounded graph reachability over an in-input adjacency — and ADR-0002 Amendment 1 records the withdrawn sentence verbatim with the reason each clause failed: cross-entry/cross-manifest checks are tier-1 expressible where the data is present and fail on input availability where it is not, which REQ-E11-S05-01's identical-EvaluationInput pin hands to the Rego tier unchanged; "complex derivations" is the named-intermediate shape, struck because inlining is semantically identical and [expr].all(v, …) computes it once; "whole-branch conventions" is the same input-availability limit renamed. The Options table's 2026-07-21 "ceiling: cross-entry logic" cell is left standing as a record of the deliberation, marked superseded rather than rewritten. Why this did not wait for E11-S12, which nominally owned it (REQ-E11-S12-01), and why the mechanism matters more than the sentence: that REQ's Test: list omitted docs/adr/0002-* — the actual wrong file — and its Verify: task check has no pin over the phrase, so no gate could ever have failed on it; it was invisible to CI by construction, and S12 is story 12 of 14, so if E11 never proceeded the ADR would have stayed wrong indefinitely. The correction was carried instead by standalone backlog residual E11-R01, which is what actually kept it from being lost. The structural hole is narrowed, NOT closed, and the difference is worth stating precisely: docs/adr/0002-policy-frontends-rego-declarative.md is now named in REQ-E11-S12-01's Test: list, so the file is inside S12's reviewed sweep instead of outside it. That is a review pin, not a gate. Nothing machine-consumes a REQ's Test: list — no script, workflow or Go test parses those lines, and the only gate that reads ADRs at all (hack/docs/truthlag_pins_test.sh) compares each ADR's Status row against docs/adr/README.md and never looks at body content. So a regression re-inserting the withdrawn sentence into ADR-0002 today still fails nothing. This row named two reasons no gate could catch E11-R01 — the omitted Test: entry and the absent pin over the phrase — and only the first is addressed; the second would need a new pin in hack/docs/, which this lane is not scoped to write and which is therefore left as named, open work rather than implied. Four D-156 round-4 residuals closed in the same pass, all doc/spec, zero Go and zero schema. F4-01 — §6's verdict table had drifted from the body it summarises: the F3-01 round (c094f15) rewrote §5 and the spec's mirror but left the planning doc's table at its F-01 wording, so row D still gave the pre-fix answer. Re-derived in both copies (docs/planning/rego-tier-ceiling.md §6 and openspec/specs/p5-e11-rego-backend/spec.md's E11-S01 mirror, which drift independently): the property is that iteration levels are syntactic, a bounded k-hop check is affordable and on a small graph complete, the adjacency is "deliverable today" rather than "available today", and the row carries §5's own caveat that no provider in the corpus ships one. Row A2 gains the value binder [expr].all(v, …), omitted in both copies, which was understating what tier 1 can express. Same class, found while re-deriving and fixed with it: the spec's E11-S01 "Binds E11-S04" paragraph still said split and graph.reachable jointly carry the graph shape, corrected everywhere else by c094f15. F4-02 — a missed cross-reference renumber: :159 still pointed at the pre-c094f15 §1.2; the claim it cites is in §1.3. Every § reference in both files was swept and each resolves to a real section; only that one was wrong. (No count is published here: the figure this row first carried was not reproducible, and any such total drifts as the files grow — an unbackable denominator is the exact defect clause (iv) exists to avoid.) F4-03 — §1.2's headline read "the number of iterations a tier-1 CEL expression performs cannot be made to depend on the data", which read alone is false: a comprehension over an input collection costs |N|, and |N| is data. The claim the record needs is its own next sentence's — iteration levels are syntactic, nesting depth is fixed by the expression text — and that is now the headline, with the false wording quoted and withdrawn rather than silently reworded, and the downstream restatements swept and aligned per the classification below — including D-156's own row above, which is the most-quoted copy of both withdrawn claims (docs/planning/rego-tier-ceiling.md cites D-156 as its Authority:). That row is amended in place rather than left standing, following the precedent of c094f15, which amended D-156 in place and is what inserted the false sentence and the six-figure costs into it in the first place; this log's convention is a living statement of the decision, not an append-only audit trail. Nothing is silently deleted: all four amended sites in D-156 quote what they published and point here. SWEEP RESULT, stated as a classification rather than as a completeness claim — deliberately, and this is the substantive lesson of the row. Three successive drafts of this correction asserted that no copy of the withdrawn claims survived, and independent review falsified all three: first D-156's own row, then openspec/specs/backlog.md, then docs/adr/0013-appendix-syntax-gallery.md:200, which was invisible to the sweep because it is written cross-*entry* — markdown emphasis inside the word, which git grep cross-entry cannot match. Per this repository's own retrospective rule, when the denominator cannot be made provably complete, fix the CLAIM, not the denominator, so this row reports what it classified: (i) METHOD — every tracked text file, each line passed through sed 's/[*_`]//g' to strip markdown emphasis before matching, for cross-entry, whole-branch, complex derivation, iteration count and each withdrawn cost figure; the un-stripped grep that missed the third survivor is superseded by it. (ii) CORRECTED — docs/adr/0002-* (bullet + Amendment 1), docs/adr/0013-appendix-syntax-gallery.md (two sites: the archetype-6 verdict and the Ceiling summary), docs/planning/rego-tier-ceiling.md, openspec/specs/p5-e11-rego-backend/spec.md, and D-156 above. (iii) LEFT DELIBERATELY, with reason — docs/adr/0008-change-classification-routing-scope.md:44's "cross-entry uniqueness" is a scope: branch input-availability feature, not this claim; and two rows in openspec/specs/backlog.md, which differ and should not be collapsed: :621 (E11-R01) still reads OPEN and still calls ADR-0002 wrong, while :602 (E11-S01) restates the withdrawn "iteration count" wording inside a row already marked ✅ DONE (D-156) — the more misleading of the two, because a reader has no cue that anything in it is outstanding. That file belongs to another lane in flight and this lane must not edit it — both are handed on, not counted as done. (iv) NOT ASSERTED — that no further copy exists. Prose this lane does not own may still carry one, and a later discovery makes this record incomplete rather than wrong, which is the whole point of writing it this way. Recorded because retaining a claim after the thing that justified it was withdrawn is this repository's own recurring failure species (D-157, D-160, D-164, D-165), and the first draft of this row committed it — it asserted completeness while D-156 still carried both withdrawn claims verbatim. F4-04, the one that is disclosed rather than fixed — §5 published evaluation costs to six significant figures (89,551/235,297/686,317 at |N|=50, 462,618 at |N|=200) while quoting the generating expression nowhere, in a document whose method everywhere else is reproduce-don't-reason. The preferred fix — quote the exact CEL expression per row — is not available: the probe was deliberately uncommitted, the expression text was never written down, and nothing in the tree recovers it; inventing a plausible frontier expression and presenting it as the one measured was rejected outright as making the record look reproducible while being false. So the figures are rounded to percentage-of-budget, the unit the argument actually uses, and a marked note records the withdrawn precision verbatim, why no reader can falsify a figure, what setup is recorded (newEvalEnv verbatim, cel-go v0.31.0, the real cel.CostLimit(1_000_000), a ring graph of the stated |N|/degree, a BFS-frontier form over the §1.1 binder), and what would close it — one committed assent test fixture with a stubbed edges fact and the frontier expression, which discharges §5's deliverability caveat at the same time. Nothing load-bearing moves: §5 uses only that cost is roughly linear in k and that a k ≥ |N| check fits at |N|=50 and not at |N|=200, and the verdict never rested on the envelope at all. What this row does NOT do. It does not reopen D-156's verdict — every shape verdict is unchanged, and this pass corrects how they were stated, not what they are. It writes no Go, adds no dependency, touches no schema. Flagged, not edited, because another lane owns the file: openspec/specs/backlog.md's E11-R01 row still reads OPEN and still describes the ADR as wrong; it should be flipped to DONE against this row. Revert: restore the rego bullet's struck sentence and delete Amendment 1 — which republishes a claim D-156 measured as false.
D-167 2026-09-03 D-162 executed: the P5-DEM epic is re-decomposed spec-first and all 54 REQ-DEM-* rows now carry Test:/Verify:/Level:. The premise was verified before acting, not assumed: openspec/specs/p5-dem-demo-repos/spec.md was 758 lines with 54 REQ rows (57 raw REQ-DEM- occurrences — the other three are the epic-prefix line and two cross-references) and zero annotations; the file's single Test:/Verify:/Level: occurrence was the 🔴 precondition paragraph at spec.md:366-373 describing the gap. D-162 required this be done spec-first and rejected annotating lane-by-lane as stories are picked up, because a story picked up with no definition of done is what produced this project's documented six tests that cannot fail. House style derived from the siblings, not invented: E10 and E11 write a three-line sub-bullet block in the order Test: → Verify: → Level: (E6/E5 write the same three fields inline); Test: names artifact paths, several of which the story creates (E10-S00 names docs/planning/github-addressing-model.md before it exists); Verify: is a runnable command; Level: is defined by GUIDELINES.md:29-31 (golden decision tests L0, adopter harness L1, cassettes L2, real GitLab L3) — internal/forge/conformance/catalog.yaml is usage of those tokens, not their definition, and the row originally miscited it as the source. This epic reads the scale one notch broader at L0/L1 (static-file gate; CLI-driven harness), stated as such at spec.md:383-395, because DEM's REQs are examples, docs and gates rather than engine semantics — the broadening matches E10/E11 usage and changes no REQ's level. Corrected while here: E10 has 54 Test: rows, not the 53 the spec and backlog both quoted. Distribution: 15 L0, 30 L1, 4 L2, 5 L3. Commands are grounded in the tree as it stands, and the row distinguishes an existing gate from an existing symbol a story must still write a gate for — TestBuiltinRepoFileMostSpecific, TestUnmatchedFileDeleteFailsSafeReview, hack/dogfood-examples.sh discovery, the hack/lint/depguard_test.sh adversarial gate and --coverage (cmd/assent/test.go:41-49) are gates that exist and run today; 🔴 CORRECTION (2026-09-03, second review round): the sentence that stood here — that ResolveFactsChecked and ScrubEnv/ScrubArgv are functions whose tests do not exist — was FALSE on both symbols, and it was false because it rested on a symbol-name grep. git grep -l 'ScrubEnv|ScrubArgv' -- '*_test.go' is empty, but that proves only that no test mentions the identifier — the exact instrument D-165 records as wrong. Read against the tree instead: ResolveFactsChecked is directly tested at internal/provider/declaration_test.go:33,72,106 and sensitive_test.go:162; and ScrubEnv/ScrubArgv are called by CallExec (transport.go:239,242) whose behaviour is gated by internal/provider/isolation_test.go:80 (TestIsolationNoWriteToken, REQ-E5-S03-01) and :146 (TestIsolationNoCredentialInArgv, REQ-E5-S03-03) — which spawn a real provider binary with UPSTREAM_TOKEN/LDAP_SECRET/CI_JOB_SECRET canaries and fail if any TOKEN-or-SECRET-named entry reaches the child. That IS REQ-DEM-S02-03's clause, so that row now points at those tests and requests no new gate; the earlier draft would have directed a story to write a duplicate. The one row that genuinely does need a gate written is REQ-DEM-S02-02 — internal/provider/guide_example_test.go is absent, and its fixture (the guide's own copy-pasteable payload) is new even though ResolveFactsChecked is well covered; it now carries the marker this row previously credited it with but which it did not have. The underlying distinction stays load-bearing: go test -run over a pattern matching nothing exits 0 (measured), so a Verify: naming an unwritten test is green-by-vacuity — the defect class this ruling exists to close. The lesson worth more than the correction: this row asserted a coverage gap from a name grep, in a ruling about not asserting properties the evidence cannot establish. The judgment this row exists to record: five REQs got an honest Level: downgrade and NO command rather than a plausible-looking Verify: that would never run. REQ-DEM-S05-04, REQ-DEM-S06-03 and REQ-DEM-S10-03 are defeated by facts already in the spec — the evaluation unit is one file (cmd/assent/run.go:266,289, singular adoptertest.Case) and the E6 provider fence (cmd/assent/test_provider_fence_test.go:69-80) keeps tier 1 on facts.yaml stubs — so each is L3, closing at DEM-S14 or not at all; REQ-DEM-S13-01 needs org credentials plus operator authorization beyond AGENTS.md rule 2, REQ-DEM-S14-01 needs a live MR, and task check is hermetic by construction. Three further REQs (S02-04, S04-03, and S11-02's screenful clause) name no command because none exists: hack/docs/example_format_inventory_test.sh reconciles examples/README.md against examples/packs/* and reads examples/repos/README.md not at all, and grepping a doc for a phrase asserts the presence of words rather than of the explanation. Two REQs that say determined by running the code (S08-02, S10-02) split their Verify: explicitly: the determination is manual and carries its commands, and the result is then locked into a golden or an expect.yaml so the seam reds on drift. One Verify: gained a second half it did not have: REQ-DEM-S09-03's identical diff APPROVEs in dev and REVIEWs in prod is satisfied by two different diffs unless a test asserts the two cases' base/↔head/ bytes are identical, so it now demands both. Scope: annotation only — zero implementation code, zero schema bytes, no demo repository. DEM-S00 stays gated by engine-grade · maintainer LGTM and was not started. One prose normalization: REQ-DEM-S10-01's inner Delete/Edit bullet list became two bolded sentences so the annotation block below it cannot read as a third sibling of them; no fact changed. Renumbered D-166 → D-167 on 2026-09-03: lane/e11-docs read decisions.md at the same max (D-165) and took D-166 first; that row was already through review by number, so this one moved. Citations in openspec/specs/p5-dem-demo-repos/spec.md and openspec/specs/backlog.md moved with it. The commit subject that first landed this row still says D-166 and is in CHANGELOG.md unamendable — the follow-up subject names the correction. Revert: git revert the DEM annotation commits and this row — the epic returns to 0 annotations and the precondition at spec.md:366-373 returns with it, which is the state D-162 ruled unacceptable.
D-168 2026-09-05 REDMAIN-N1 and REDMAIN-N2 are closed together, as the INBOX required: a literal-emoji commit subject is now rejected by a gate, and changelog_gate_test.sh §8's ### Other detector is no longer blind to that same shape. Both premises were verified in-tree at 20c80cb before anything was written: rendering cliff.toml puts THREE entries under ### Other, and the third — - 👷 ci(docs): stop uploading the Pages artifact on pull requests (dfdae69) — declares the fileable type ci, which is exactly what §8 forbids, while §8's pattern ^- :[a-z0-9_]+: ($FILEABLE_TYPES)[(:] requires an ASCII shortcode and therefore could not see it; the OK line still printed its hardcoded "revert + one malformed subject" prose against a 3-line block. (a) The rule enforced is narrow on purpose: the first character of a commit subject must be ASCII — NOT "the subject matches the whole convention". Measured over the whole of this repository's history, two legitimate shapes carry no gitmoji at all (Dependabot's build(deps): bump …, GitHub's Merge pull request #N from …), so the full-convention rule would have rejected 30+ commits it has no business rejecting, while the narrow rule has exactly ONE hit in the whole history — dfdae69, the defect itself. (No commit count is quoted: it grows with every push, and a self-dating denominator in this log rots.) Chosen over a full commit-lint (rejected: false positives on bots, and it is a bigger decision than this defect needs) and over a git hook (rejected: hooks are not committed, not enforceable, and invisible to CI). (b) Placement: no 22nd task check stage. CHECK_STAGES in hack/audit/exitgate_test.sh asserts the Taskfile's check: list is EQUAL to it, so a new stage is a change to that pin — and that file is outside this lane's fence in a four-lane parallel wave. The gate is reached instead through the EXISTING release-changelog-gate-test stage (§9 runs it) and, more importantly, as an UNGUARDED step of verify.yaml's verify: job. That is deliberate asymmetry with the changelog drift gate one screen below it, which stays github.event_name != 'pull_request' under D-148/OQ-30: a pull request is the LAST moment a bad subject can still be reworded, because once it is on main hard rule 2 forbids rewriting it. Per REDMAIN-R1 a green PR is otherwise no evidence at all here — this is the first changelog-integrity check that actually runs on pull_request. (c) Published history is tolerated by full commit SHA, never by shape. dfdae69 cannot be rewritten (hard rule 2), so it sits in LEGACY_ALLOW_SHAS; a SHA cannot be inherited by a future commit the way a pattern-shaped exemption could. Three self-checks keep it honest: every listed SHA must resolve here and be an ancestor of HEAD, every listed SHA must ITSELF still be a detection (a stale exemption reds the gate), and the detector is re-proved on every run against a fabricated known-bad/known-good pair so a mistyped pattern fails loudly instead of matching nothing — the vacuity mode this repo has shipped twice. The list's LENGTH AND CONTENT are additionally pinned from a SECOND file (LEGACY_EXPECTED in changelog_gate_test.sh §9d, set-equality, both sides guarded non-empty): review found that all three in-gate self-checks still pass if a lane lands a literal-emoji commit and appends its SHA in a later commit of the SAME pull request — it resolves, it is an ancestor, it genuinely IS a detection — so an allowlist whose size nothing pins can grow unremarked. Growing it is now a deliberate two-file change plus a row here, the same ratchet CHECK_STAGES applies to the check: stage list. (d) The two halves keep SEPARATE lists, linked one way only — and the first attempt at this was WRONG. §8's ### Other exemption was initially derived from LEGACY_ALLOW_SHAS on a "one authority, no drift" argument. Review simulated REDMAIN-N3 landing and showed that design has no green state afterwards: the two lists answer different questions that decouple at exactly that moment — LEGACY_ALLOW_SHAS asks is the commit SUBJECT still a literal emoji (permanent: dfdae69's subject can never change), §8 asks is the RENDERED ENTRY still mis-filed (temporary: N3 makes it false). Coupled, §8 red with a remedy that could not be executed — dropping the SHA reddened the commit-subject gate on dfdae69 AND reddened §8 again on its own non-empty guard. §8 therefore keeps its own OTHER_EXEMPT_SHAS, which MAY be empty (empty is the post-N3 steady state, and with no exemption in force every ### Other line is checked — strictly stronger), and the only remaining link is the one-way subset invariant OTHER_EXEMPT_SHAS ⊆ LEGACY_ALLOW_SHAS, read live through --legacy-shas, which the empty set satisfies and which therefore cannot deadlock. Recorded as a correction rather than silently rewritten: a "single authority" is the right instinct for two facts that stay equal, and the wrong one for two facts that are scheduled to diverge. (e) What is NOT fixed, and why: the rendered entry still sits under ### Other on the Release page. Re-filing it needs a cliff.toml commit_parsers entry and cliff.toml is outside this lane's fence — filed as REDMAIN-N3 in openspec/specs/backlog.md. The exemption is built to force that hand-off: when N3 lands, the exempt entry stops appearing under ### Other and §8 reds — and the remedy it prints is now one the N3 lane can actually execute: delete that SHA from OTHER_EXEMPT_SHAS in changelog_gate_test.sh §8, and nothing else; do NOT touch LEGACY_ALLOW_SHAS. Proved end-to-end in a scratch clone, not argued: a cliff.toml parser for the literal-emoji prefix was added, CHANGELOG.md regenerated (the entry moves to Chores), the gate went red with that message, the message was followed literally — one line deleted — and both gates came back green. The control run, deleting the SHA from LEGACY_ALLOW_SHAS instead, still reds on dfdae69, which is what the coupled design forced. A red for a good change is the intended ratchet; a red with no green state on the far side was a defect. Incidental finding, recorded because it changes what a gate may cite: AGENTS.md and CLAUDE.md exist in the working checkout but are untracked (not gitignored — simply never committed), so no gate can pin rule 5 against them; the gate and its docs cite GUIDELINES.md § Repository discipline, which is tracked and says the same thing. Non-vacuity: eight mutations run in a scratch clone, each red for its own stated reason and restored green — exemption deleted (reds naming dfdae69), detector pattern broken (reds at the self-proof), OTHER_MAPPABLE_RE reverted to the prefix-required form (§8b reds), §8's exemption subtraction removed (§8 reds on the REAL rendered 👷 ci(docs) line — the proof that Half A is no longer fail-open), exemption made stale (reds with the retire message), verify.yaml step deleted / guarded off pull_request / given a repo argument (§9a reds on each), gate script deleted (§8 reds). Fix round adds four more, all red for their own reason and restored green: the reviewer's allowlist-growth attack (a literal-emoji commit landed and its SHA appended — the gate itself still says OK, §9d reds on the pin), LEGACY_EXPECTED emptied (reds on an array-LENGTH guard, because printf '%s\n' "${a[@]}" on an empty array writes a blank line and a file-size guard there would never have fired), --legacy-shas made to print nothing (reds rather than comparing two empty files), and §8 exempting a SHA absent from LEGACY_ALLOW_SHAS (reds on the subset invariant). Review also found §8's first detector still shape-specific — it accepted exactly one non-ASCII token and exactly one space, so 👷 ci(docs):, 👷ci(docs): and 🚀 :rocket: feat(x): slipped past, the same species of defect REDMAIN-N2 was filed against. The quantifiers are now general and §8b's probe is 12 lines with a line-by-line expected verdict. (f) A truth-claim this row must not overstate, found by the third reviewer and recorded rather than quietly narrowed: the three properties (d) and (e) are really about — the subset invariant, the empty-exemption-list path, and the retire message — have no standing control. Running changelog_gate_test.sh on the real tree exercises none of them: the subset grep always passes, the empty-list branch is never taken, and the retire message is never emitted. They were proved by one-off scratch-clone simulation, which is evidence this lane produced once, not a probe that re-runs — a materially weaker guarantee than §8a/§8b/§9b/§9d, which each carry a standing mutation. REQ-REDMAIN-N2-02's Test:/Verify: line now says so in those words instead of implying the script covers them. Closing the gap is REDMAIN-N4 (openspec/specs/backlog.md), a tracked OPEN residual with its own row: a §8c driving all three over a fabricated ### Other block, the way §8b drives the detector over a probe. Deferred deliberately to its own lane — it is a new gate surface and this one had already taken two fix rounds — not because it is optional. Three fail-closed P3s ride on that row: §9d's mutant runs in foreign mode (comment precision, the mutation still proves its claim), grep -v -x -F -f would mis-message if two commits ever shared an exempt subject, and OTHER_MAPPABLE_RE also flags non-emoji non-ASCII prefixes that the commit-subject gate makes unreachable. Revert: delete hack/release/commit_subject_gate.sh, revert §8's detector to ^- :[a-z0-9_]+: ($FILEABLE_TYPES)[(:], drop §8b/§9 and the verify.yaml step — the repository returns to a state where a literal-emoji subject lands unchallenged and is published under ### Other, which is the state REDMAIN-N1/N2 were filed against.
D-169 2026-09-05 Two residuals in hack/lint/bash_version_guard_test.sh — BOTH premises confirmed by measurement before a line was written, and both are the same species one layer apart: a claim that outruns the sweep behind it. N5 — a sentinel that proved the wrong thing. The four PATTERN_NONPROBES assertions ("this line puts the construct somewhere other than a command position, so NOTHING may detect it") were partnered by a hack/sentinel.sh fixture written into the same root. That partner proves the fixture ROOT was walked; it never proved probe.sh itself was enumerated, and those are different claims. Measured, on a scratch copy of the gate run against a scratch copy of hack/: (a) moving ONLY the probe write to $proot/unwalked/probe.sh while the sentinel stayed at $proot/hack/sentinel.sh → all four assertions green, 0 FAILs, exit 0, 57 PASS lines; (b) naming the fixture probe.txt so find -name '*.sh' never lists it → identical result, 0 FAILs, exit 0, 57 PASS. Both runs were byte-comparable to the honest run: nothing in the gate's output distinguished "the scan cleared this file" from "the scan never opened this file", because the scan's OUTPUT cannot carry that distinction. Fix: the population was factored out of scan_features into enumerate_scripts <root>, which is now the single definition of what gets graded, and each absence assertion names the fixture path explicitly and requires an exact-match hit in that enumeration before it is allowed to conclude anything. Non-empty guards on both sides (the enumeration must be non-empty, the fixture path must be non-empty, and the file must exist on disk) so a mistyped path reds loudly instead of passing vacuously. The sentinel STAYS — it is not redundant: enumeration proves the file was offered to the scan, the sentinel proves the scan can still report at all, and a scan degraded to print nothing would satisfy the first while failing the second. MUST_BE_CLEAN had the identical hole one check down — its anti-vacuity partner was [ -f ], which proves existence and not membership (a file at hack/lib/require-bash or hack/audit/README.md exists and is still outside the population) — so it now asserts membership in the same enumeration. A new enumeration control pins the enumerator in both directions: it must list hack/sentinel.sh, and must list NEITHER a file outside hack/ NOR a non-.sh file. A second control pins the COUPLING the whole fix rests on — that scan_features grades the population enumerate_scripts defines, which today holds only by construction: on a fixture root carrying three feature-using .sh files at three depths plus two feature-using files deliberately outside the population (one non-.sh, one outside hack/, without which the subset half would be unfalsifiable), the two lists must be equal. Both halves are needed and neither implies the other: a WIDER divergent find inside the scan is caught by the subset half, a NARROWER one only by the superset half. Mutation-proven each way — find "$root" -type f inside scan_features reds naming hack/notscript.txt and outside/away.sh; a -maxdepth 1 walk reds naming hack/a/b/deep.sh and hack/c/mid.sh; an empty scan reds the non-empty guard rather than comparing two empty sets. ROUND 3 — that control had a NO-VERDICT branch, found by the review that approved the rest. It compared the two lists with an ORDER-SENSITIVE string equality while diagnosing the difference by SET membership, so a divergent find yielding the same set in a different order failed the equality, satisfied both membership loops, and emitted neither a PASS nor a FAIL: measured, sort -r inside scan_features gave EXIT=0, 70 PASS, 0 FAIL and zero "population coupling" lines, and since nothing in this gate pins a total PASS count, a silent control reads exactly like a satisfied one. Graded P2 only because that mutation is harmless (identical graded population, N5 fix intact) — but a control that can stop asserting without saying so is precisely the defect class this lane exists to remove, and the fix does not get an exemption for being the fix. Closed at the CLASS level, both halves: (1) both sides are now sorted before comparing, because order is not part of the invariant — what must hold is that the graded and enumerated populations are the same collection, not that two functions emit them in the same sequence; plain sort, never sort -u, so a duplicate still differs instead of being collapsed into agreement; (2) the else-branch always returns a verdict — the per-file loops record whether they said anything and a diagnosis-free difference is itself a FAIL, so no future difference the loops cannot name (multiplicity, trailing whitespace) can go silent. Mutation-proven: sort -r now yields a deliberate PASS with the coupling line present and 71 PASS (the honest answer — the population is identical); grading every file twice fires the catch-all naming both multiplicities; the wider, narrower and empty-scan mutations still red as before. The per-file loops were also converted from unquoted for word-splitting to while IFS= read -r, so paths carrying spaces or glob characters cannot be split or expanded — unreachable on this fixed fixture, but this file has a history of quoting scars. Mutation-proven, each red for its own reason: misdirection (a) → 4 FAILs, exit 1, "was NOT enumerated as 'unwalked/probe.sh'"; misdirection (b) → 4 FAILs, exit 1, same message naming hack/probe.txt; find "$root" -type f (walked root widened) → the enumeration control alone reds with "listed a file OUTSIDE hack/"; -name '*.sh' dropped → "listed a non-'.sh' file"; a MUST_BE_CLEAN entry repointed at the existing hack/audit/README.md → "exists but is NOT in the population scan_features grades". N6 — a completeness claim outrunning its sweep. The header's "GUARD SHAPES NOT RECOGNISED" block said "If you hit that message on a file you know is guarded, this list is why". Every claim the list actually made was TRUE; only the framing overreached. Measured: case "${BASH_VERSINFO[0]}" in 0|1|2|3) exit 1;; esac is a real guard, is rejected by has_versinfo_guard (a case head is a comparison containing no operator character at all), and was unlisted — as are guards reading $BASH_VERSION rather than the BASH_VERSINFO array, guards split across lines (the predicate is a line-wise grep), [[ … =~ ^[0-3]$ ]] tests, and ${BASH_VERSINFO:0:1} substring expansions. Three fixes were available and the choice matters. (a) Widen the sweep — REJECTED: the rejected set is every spelling that is not the one accepted spelling, so no enumeration of it can be complete and a longer list restates the same false claim in more words; widening the predicate to accept case would also relax an intentionally strict fail-closed rule for a shape no file in this tree uses. (b) Weaken the claim to what is true — necessary but not sufficient on its own: prose that is accurate today rots the same way the old prose did. (c) Make the list graded rather than hand-maintained — chosen, together with (b). The block now states the ACCEPTED rule instead of enumerating the rejected ones, says explicitly that the named shapes are a measured SAMPLE and not a catalogue, and the shapes themselves moved into GUARD_SHAPES_UNRECOGNISED where control (i) asserts each is still rejected end-to-end through grade_root — the same path hack/audit/aud2_exitgate_test.sh's green depends on. The pre-existing realguard acceptance loop directly above is control (i)'s partner, so a predicate that rejected everything cannot satisfy it. Mutation-proven: widening has_versinfo_guard's operator alternation to accept a case head made control (i) red with exactly the drift message, naming the case entry and no other. ROUND 2 — the replacement prose committed the SAME overreach one corner smaller, found by independent review of this very change. The new block asserted "THAT RULE IS THE COMPLETE STATEMENT" and this row said "the ACCEPTED rule, which IS complete". It was not: has_versinfo_guard is anchored ^[^#]*BASH_VERSINFO…, so any # earlier on the line rejects the guard, and that constraint appeared in neither the stated rule nor the older explanatory block above the function which the new text defers to. Measured, both satisfying the stated rule verbatim and both REJECTED: [ $# -gt 0 ] && [ "${BASH_VERSINFO[0]}" -lt 4 ] && exit 1 and if [ $# -eq 0 ] || ((BASH_VERSINFO[0] < 4)); then exit 1; fi — $# is simply how an argument-checking prologue is spelled, and ${#arr[@]} and ${var#pfx} do the same; a # AFTER the token is harmless, which is why it went unnoticed. The lesson is the durable part: prose restating a regexp is a DERIVED claim and cannot certify itself, however carefully the rule is phrased — stating a rule instead of a list removes the enumeration problem, not the drift problem. So the header now says exactly that (the regexp is the authority; what a test keeps honest is the array, not the paragraph), the # clause is stated in BOTH places — the header rule and the three-requirement block above has_versinfo_guard, now "NO # BEFORE THE TOKEN" alongside ADJACENCY and A VERSION ON THE OTHER SIDE — and both $# shapes are graded entries in GUARD_SHAPES_UNRECOGNISED. Round 3 found the same omission once more, at P3: the rule named neither the OPERATOR SET nor the exclusion of bare = and !=, so three of this row's own graded entries ([ "${BASH_VERSINFO[0]}" = 3 ], [[ … != 4 ]], (( … != 4 ))) satisfied every condition the paragraph stated and were rejected. Graded P3 rather than P1 only because — unlike the # case, documented nowhere — the answer sat fifteen lines below in the graded array the paragraph points to, with its reason. The rule now names the set explicitly. Deliberately NOT narrowed to a leading-# anchor: a comment can be indented or trail code, so separating "this line is a comment" from "this line contains a #" is the quoting/parsing problem that produced this file's other scars; it stays a documented, graded fail-closed limit. Mutation-proven: replacing the ^[^#]* prefix with ^.* made control (i) red on exactly the two $# entries and no others. Species note: N6 is the third occurrence in one session of the failure recorded by D-157, D-160, D-164 and D-165 — a completeness claim outrunning the sweep behind it. The generalisable remedy is the one applied here: a documented enumeration that grades behaviour must be an ARRAY the script iterates and asserts, never a prose list. Stating the RULE rather than the instances is a real improvement — it removes the enumeration problem — but this row is itself the evidence that it does not remove the drift problem, and a future lane must not read it as licence to trust a carefully-phrased paragraph: round 2's rule omitted the # anchor, round 3's omitted the operator set, and neither omission was visible to anything but a reader who went to the regexp. Prose beside a predicate is a DERIVED claim in every form. What holds is the graded array; the prose should say so, and should name the code as the authority rather than restating it as if the restatement were the contract. Scope held: every hack/** script under judgment is byte-unchanged; this is a change to the checker only. Revert: revert this commit — the four absence assertions return to being satisfied by a fixture nobody looked at, and the header returns to claiming a completeness it does not have.
D-170 2026-09-05 A docs/ page that is not reachable from the published site can no longer sit there unnoticed: nav membership is gated, and the only sanctioned alternative is an explicit, commented, justified exclusion. The premise handed to this lane was re-measured and holds exactly: mkdocs build --strict on main at 20c80cb listed 56 of 66 docs/**.md files as absent from nav:, green, because mkdocs.yml:65 set validation.omitted_files: info and --strict escalates WARNING and above only. Two prior spec rows already stated the limitation in prose without closing it (REQ-DEM-S01-03, REQ-DEM-S02-01: "nav linkage is therefore manual review unless a later change bumps omitted_files to warn"). What the number was hiding is the part worth recording. It conflated two populations that nothing in the repo could tell apart: 25 product pages that simply fell out — all 22 ADRs, docs/architecture/policy-profiles.md, and both frozen docs/contracts/p3-e5-publication-protocol/ documents, which E9-S08/D-103 never re-added when it trimmed the nav to product pages — and 31 pages that are deliberately not product docs: docs/planning/** (27, kept out of the nav by GUIDELINES.md "Repository discipline" and by D-103 itself), the two docs/assets/** maintainer runbooks for regenerating brand SVGs and VHS tapes, docs/decisions/evidence/** (a per-story evidence artifact reachable from the decision log), and docs/adr/template.md (an authoring stub whose body is literally ADR-nnnn: Title). So the defect was never "56 pages are missing"; it was that deliberate exclusion and accidental omission were indistinguishable, and the one check that could have separated them was pinned below the level --strict acts on. Decision, three parts. (1) The 25 product pages go into nav: — the ADRs as a full section under the existing ADR index, policy profiles under Architecture, and a new Contracts section. (2) The 31 others are named in a commented not_in_nav: block, one comment per group stating why that group is not a product page; adding a glob there is a publication decision, not a way to silence a page that merely lacks a nav row. (3) validation.omitted_files is raised to warn — error was tried first and mkdocs rejects it for that key (Expected one of ['warn', 'info', 'ignore']), which is exactly why part (4) exists. not_in_nav: and NOT exclude_docs:, deliberately. Excluding the planning subtree from the build was the stronger-looking option and is wrong here: 7 published pages carry 19 links into docs/planning/**, 10 of them from docs/decisions/decisions.md, so dropping those pages out of the site turns every one of those links into a --strict failure and forces edits to existing decision-log rows. not_in_nav keeps the pages built and link-reachable while stating, in the config, that they are not navigation destinations — which is precisely what GUIDELINES.md asks for. The gate's own guard, because warn means the gate lives or dies with --strict. .github/workflows/docs.yaml gains a step that (a) greps its own Build site step for mkdocs build --strict and (b) writes a page that is in neither nav: nor not_in_nav:, runs the strict build, and asserts it fails naming that page. Mutation-verified in four states before landing: armed → guard green; omitted_files back to info → guard RED ("the gate is NOT armed"); not_in_nav widened to ** → guard RED; --strict removed from Build site → guard RED naming that removal. The gate itself was proven able to return "found" the same way: a scratch unlinked page reds the strict build under the new config and builds green under the old one, so this is new signal rather than a restatement of something already covered. This is the repo's own D-123/D-124 idiom — a gate nothing guards is a gate nobody notices being deleted. Publication review, the highest-risk part of this lane. All 25 newly navigated pages were read in full against AGENTS.md hard rule 1 before exposure: no employer names, no internal system names, no internal policy content, no verbatim private material. Examples throughout are generic (platform/orders-service, topic-registry:orders.events.v1, Kafka-topic and tfvars shapes); the only third-party names are public products (GitLab, GitHub, Kyverno, OPA, cel-go, Keycloak, Entra ID). hack/check-sanitization.sh passes over the tree. The three internal-looking pages were checked too and are excluded on editorial grounds, not secrecy: they are already-public maintainer runbooks and evidence, not product pages. What was verified beyond the build being green, because a green build was the whole problem: the site was built and inspected — 66 HTML pages, 3 816 internal links, 0 broken; all 25 new pages render with substantial bodies; every one is reachable from the rendered sidebar; no duplicate nav label and no duplicate page <title>. Not done, on purpose: validation.anchors stays info. It is the same invisibility class (one real broken anchor exists today in docs/planning/spikes/spike-secure-setup.md and builds green under --strict), but it gates intra-page targets rather than site reachability and needs that anchor fixed first — tracked as DOCSNAV-R01. Also not done: wiring task docs-build into task check. It is not a check: stage today, so nav completeness is a CI-side gate only; closing that would edit Taskfile.yml, which is outside this lane's fence, and is left as a stated gap rather than a silent one. Revert: git revert this lane's commits — mkdocs.yml returns to omitted_files: info with a 10-entry nav, the 25 pages return to being built-but-unnavigated, and the workflow guard goes with them; the pages themselves are never deleted by either direction.
D-171 2026-09-05 DOC-TRUTH — the SONAR-SHELL backlog row asked for 54 SonarCloud shelldre findings to be cleared in four CI-gate scripts; all 54 had been cleared 33 days earlier, and the row still said OPEN. Same species as [D-157], [D-160], [D-164] and [D-166]: a tracked file asserting work that no longer existed. The number is 54 of 54. An earlier draft of this row, of openspec/specs/backlog.md:97, and of commit eed18ad's subject said 52 of 54; that figure is WITHDRAWN. It was 54 minus the 2 sites outstanding today, which silently conflates the original population with a later one and is the exact claim-versus-evidence defect this row exists to condemn. Measured at 9a772d4: 34/34 S7688, 9/9 S7679, 3/3 S131, 1/1 S7684, and both remaining families cleared — zero residual in any of the six. eed18ad and the CHANGELOG.md entry generated from it still carry the withdrawn 52; they are superseded, not erased (no force-push, hard rule 2; the changelog is regenerated, never hand-edited). The timeline, to the hour. c2a6ab7 introduced the row at 18:06 on 2026-08-03 from a live SonarCloud query taken earlier that day; 9a772d4 (style(hack): shell-script hygiene per SonarCloud shelldre rules) landed at 22:23 the same evening and applied every rule family the row names. The row was stale after 4h17m and nobody re-read it for 33 days. Two independent corroborations. First, arithmetic over the repo's own recorded live queries: 2026-08-03 totals 115 = 46 go:S3776 + 54 shell + 15 misc, and the 2026-08-13 re-query at agent-context/archive/INBOX-2026-08-16.md:71-72 reports 66 CODE_SMELL — 66 cannot still contain the 54, because 66 − 54 = 12 is fewer than the 46 go:S3776 alone. That query reports the total only; "no shell findings remained" is derived from that arithmetic, not quoted from it. Second, [D-150] (2026-08-16) describes the same residue as "concentrated in 34 table-driven Go test files". The 2 S7688 sites outstanding today are a separate, later population, not a remainder of the 54: 895ff7e (BASH32-F01 / [D-154]) introduced them at hack/validate-schemas-stock.sh:55-56 20 days after 9a772d4 cleared that set. They are deliberately not converted here, and are tracked as SONAR-SHELL-R01 in openspec/specs/backlog.md — a standalone residual row, because a residual mentioned only inside a row that has just flipped to DONE leaves tracking with it, which is precisely what [D-166] recorded about E11-R01. They are one copy of a four-site idiom whose canonical spelling is the USAGE block in hack/lib/require-bash.sh:40-41, with siblings in hack/release/verify-artifacts.sh:21-22 and hack/docs/truthlag_pins_test.sh:45-46; moving one copy puts the idiom at odds with its own canonical form for a 2-issue non-gating win, so they must move together. What this lane contributes is the evidence that was missing. 9a772d4's message claims "byte-identical behaviour … no check semantics, exit codes, output strings, or control flow changed" about four scripts that are the CI gates, and that claim had never been tested — the classic way a [→[[, an invented *) arm, or an added return converts a working gate into a fail-open one while every other gate stays green. A differential harness ran all four scripts in three revisions (9a772d4^, 9a772d4, 20c80cb) against happy-path and negative inputs — denylist hit / miss / missing-file, a planted internal hostname, a planted employee-ID, a base64-encoded denylist term inside a YAML value and the same payload in a non-YAML file that must stay clean, a DRAFT marker, a rego file with no quarantine marker, one whose marker follows the first code line, one where it precedes it, a stray rego: leaf, the negative fixture edited until it validates, the whole permanent-negative directory removed (the non-vacuity backstop fired), an unknown kind, an out-of-group fixture that must skip, and an unknown spike profile — byte-comparing exit status, stdout and stderr. Every case was identical in all three revisions. Non-vacuity was proven with four mutants, each red for its own reason: turning check-migration-invariants.sh's *) break ;; arm into *) ;; flips its negative case 1 → 0, a real fail-open; deleting smoke.sh's *) profile arm replaces a named refusal with BASE: unbound variable; swapping the two locals report() now binds silently relabels every HIT line; and moving report_fail()'s explicit return above its fail=1 flips the stock-validator's negative case 1 → 0. On that first mutant, stated precisely: 9a772d4 did not touch *) break ;; — that arm predates it and is context in its diff. 9a772d4's S131 fix added *) ;; to the adjacent case statement five lines above (:55-58, on $line), while *) break ;; belongs to the one below it (:61-64, on $trimmed). The hazard the mutant demonstrates is therefore sharper than "S131 is risky": where two case statements sit adjacently, an S131 add a default arm aimed at the wrong one silently converts the gate to fail-open, and only a negative test over the ordering it enforces can tell the two apart. Marked note — one withdrawn precision, per [D-166]'s F4-04 remedy. An earlier draft published "59 checks … all 59 identical". That figure is withdrawn: the harness lived in a scratch directory, was never committed, and is gone with the session, so no reader can falsify the count. The preferred fix — commit the harness as a standing gate — is not available to this lane: it belongs under hack/lint/, wired into Taskfile.yml's check: and pinned in CHECK_STAGES, all outside this lane's fence with three lanes in flight, and landing it unwired would itself be the gate invoked by nothing defect [D-124] exists to forbid. Reconstructing a plausible harness and presenting it as the one that ran was rejected outright as making the record look reproducible while being false. What is recorded and re-runnable: the three revisions by SHA, the enumerated case list above, the comparison method (exit status + stdout + stderr, byte-compared, with bash's own <path>: line N: diagnostic prefix normalised because the three legs run from different paths), the pinned validator (npm ci --ignore-scripts --prefix hack/schemas-validator), and GNU coreutils 9.11 ahead of this box's uutils build. What would close it: SONAR-SHELL-R01 carries the harness as a standing-gate candidate. The argument here needs only that every case matched in all three revisions, which an independent reviewer reproduced over a subset. Gate matrix green on the unchanged scripts: task check EXIT=0 across 21 stages, task coverage 91.1% against the 91% floor. The four scripts are byte-unchanged by this lane — the correct outcome when the work is already done, and the reason this row records the real numbers instead of a padded 54.
D-172 2026-09-05 DOC-TRUTH — the SONAR-GO-MISC backlog row claimed 15 open SonarCloud findings that had ALREADY been fixed, in full, ~5 hours after the row was written; it then read OPEN for 33 days. The remediation plan landed in c2a6ab7 (2026-08-03 18:06 +0200); 5970e97 (2026-08-03 23:03 +0200, :art: style(go): clear SonarCloud maintainability smells (SONAR-GO-MISC)) closed every one of the seven rule families the row itemises. Verified target-by-target against the tree rather than transcribed from the commit message: godre:S8184 ×4 — all four _ "embed" imports in schemas/{approval_test,compiler,provider_validate,testfixture_test}.go now documented; go:S1186 ×4 — the four isNode() markers in hack/spikes/cel/tree.go now carry inline comments; godre:S8196 ×2 — Node→Noder (hack/spikes/cel/tree.go), Clock→Clocker (internal/forge/forge.go); godre:S8205 ×1 — hack/spikes/provider/config.go anonymous structs extracted to ConfigRequests/ConfigValues; go:S107 ×1 — internal/change/entryref.go diffOneEntry went 8 params → 6 via the entrySide pair; go:S1192 ×1 — cmd/assent/run.go now holds one sha256Prefix const and one literal. go:S1135 ×2 was audited specifically for the "delete the marker, drop the work" failure and is CLEAN: test/e2e/skeleton_test.go's two TODO(S10)/TODO(P4-E1-S10) notes were reworded to "deferred to … (infra-gated)" with the enumerated deferred work (boot GitLab, seed, open MR, run the binary, assert thread/approval/merge, validate the DecisionRecord) preserved verbatim, and the owning story is still tracked at backlog.md:63 (P4-E1-S10, [infra-gated: needs live GitLab / real repo]). Nothing was silently dropped. Evidence is a live SonarCloud verdict, not only static analysis: the public api/issues/search rules= facet returns 0 for all seven families, with non-vacuity proven by a positive control (go:S3776→61 on the same filter) and a mixed control (the same seven rules plus shelldre:S7682→1, so a multi-rule filter demonstrably does not collapse to 0). Total open = 66 (go:S3776 61 · godre:S8193 4 · shelldre:S7682 1). Second, larger finding, recorded because it invalidates the whole section's numbers: SonarCloud has not analysed this project since 2026-08-04 — api/components/show gives analysisDate = 2026-08-04T08:01:09+0000 and no open issue has a creationDate after 2026-08-04T07:32Z, i.e. ~a month of commits on main are unscanned. The 2026-08-04 analysis still validates this row's closure (it postdates 5970e97 by ~11h), but every other SONAR-* count is a month stale. Corroborating that the scan is stale rather than clean: a calibrated local AST/revive pass over the current tree finds smells of these same shapes in code created 2026-08-04 or later — 5 functions above the 7-parameter budget (cmd/assent/provider_host.go ×2, cmd/assent/run.go decide/buildDesired, internal/compare/record.go), and an undocumented _ "embed" in internal/render/locale/catalog.go — none of which SonarCloud reports, because it has not looked. Deliberately NOT fixed here: that drift is not this row's 15, the parameter-count items are exported-API-shape judgement calls, and "fixing" against a month-stale scan is precisely the mechanical-edit failure this project keeps re-introducing. It needs its own lane once a fresh scan exists. Recommended next action for the operator: find out why Automatic Analysis stopped on 2026-08-04 and re-enable it — the SONAR-* backlog is otherwise being sequenced against numbers nobody is refreshing. Same species as D-164/D-157/D-160: a claim retained after the thing that justified it was withdrawn. Revert: restore the row from git history.
D-173 2026-09-05 DOC-TRUTH — [D-171]'s correction was itself wrong: 9a772d4 cleared 53 of the 54 shelldre findings, not 54. The 54th was real, was open, and is closed by this lane. [D-171] published, verbatim, "The number is 54 of 54." — and separately "zero residual in any of the six". Both figures are WITHDRAWN. They are superseded, not erased: eed18ad, 20c80cb and the CHANGELOG.md entries generated from them (CHANGELOG.md:139-141) still carry the withdrawn 52 and 54 of 54, and stay as they are — no force-push (hard rule 2), no --amend, changelog regenerated and never hand-edited. The measurement, with its instrument. SonarCloud REST, anonymous, 2026-09-05, `GET /api/issues/search?componentKeys=PlatformRelay_assent&rules=shelldre:&resolved=true
D-174 2026-09-05 The nav-completeness gate now reds task check locally, and no task check stage count or ordinal survives as a standing claim in any file this lane owns: nine stale or falsified ones are gone, the correct ones are gone too, and two that this lane itself published wrong — an ordinal, and a completeness claim about ordinals — are recorded rather than smoothed over. The sweep that finally found them all is LINE-JOINED, and that is the transferable part of this row. Substance first, in the order it matters. (A) DOCSNAV-S02 — the gate had zero local pre-merge evidence. [D-170] armed nav completeness in .github/workflows/docs.yaml alone: mkdocs.yml sets validation.omitted_files: warn, --strict escalates it, and a docs/**.md page in neither nav: nor not_in_nav: fails the build. But task docs-build was not a check: stage — the D-170 spec fenced that off explicitly — so task check, the per-commit precondition this repository grades everything else against, was green over exactly that page, and the first signal was a red docs workflow on main, which also fails the Pages deploy. Not hypothetical: an uncommitted ADR draft in the shared checkout (docs/adr/0022-…, another writer's live work, untouched by this lane) is in neither list today and would have reddened main the moment anyone committed it. docs-build is now a check: stage, and CHECK_STAGES in hack/audit/exitgate_test.sh moved 21 → 22 in the same commit — that array is asserted EQUAL to the Taskfile list (:539), so the halves cannot move apart. Deliberately not a second implementation: the stage runs the same mkdocs build --strict against the same mkdocs.yml as the CI Build site step. A bash re-derivation of nav:/not_in_nav: matching was rejected — it would be free to drift from the thing CI grades, which is the defect this epic exists to remove. Costs, measured and stated rather than left to be discovered. Time: ~1.7s for the build; docs-install (uv venv + hash-checked uv pip install) is cached by go-task's sources:/generates:, and it is the one check: stage that is not offline on a cold machine — accepted knowingly, and verify.yaml's release-exitgate job already installs Python and uv for this task, so the task check the AUD-S18 gate runs there needed no new setup. Toolchain: task check now hard-requires uv and Python 3.12 on PATH. That is a new prerequisite for the per-commit gate and it bites harder than it reads, because mise.toml is untracked — an environment carrying Go and golangci-lint but not uv reds at docs-build on a tree with nothing wrong in it. Working tree, not commit: mkdocs build --strict reads docs/ as it is on disk, so an untracked, in-progress page in neither list reds task check and blocks every unrelated commit until it is listed. Concretely and immediately: the shared checkout's in-progress docs/adr/0022-container-image-distribution.md is in neither list (the nav carries ADR-0001..0021), so its author's next task check goes red the moment this lands. That is the gate working — the same red they would otherwise have taken on main, moved earlier and made cheaper — but the unblocking move is published rather than left to be guessed, in Taskfile.yml beside the stage and in the S02 spec: give the page its nav: row if it is product documentation, or a justified not_in_nav: glob if it deliberately is not (the REQ-DOCSNAV-S01-02 publication decision), or keep a genuinely scratch draft outside docs/ until it is ready. Widening not_in_nav: to swallow the tree is not a third option — the D-170 workflow guard reds on exactly that. Proved in both directions before the row was written: an untracked docs/zz-…-nav-probe.md made task check abort at the docs-build stage naming that page, and a page under docs/planning/** (a not_in_nav: pattern) left the stage green — the stage grades unlisted, not new. What it does NOT close, named rather than implied: deleting - task: docs-build from check: is caught only by that CHECK_STAGES equality, whose sole CI caller is release-exitgate, guarded if: github.event_name != 'pull_request' — the RELSE-08 visibility class, pre-existing and shared by every other check: stage. Filed as DOCSNAV-R02; bolting a PR-visible list-equality assertion onto hack/audit/aud2_exitgate_test.sh, whose REQs are the 2026-08-18 remediations, was declined rather than given an invented scope. (B) DOCTRUTH — eight ungraded counts, and the lesson that only showed up when a reviewer re-measured. Inertness was re-confirmed here rather than taken from the filing. The readers of the fenced files are: check_gate_documented (a grep -Fq exitgate_test.sh over hack/audit/README.md), four fixed-string backlog greps in hack/release/exitgate_test.sh / hack/compare/exitgate_test.sh / internal/compare/exitgate_test.go / internal/render/exitgate_test.go, and hack/lint/workflow_pins_test.sh over verify.yaml (pins, steps, disarm shapes — no digits). Nothing parses a stage count or ordinal out of any of them. The one graded count is CHECK_STAGES, asserted EQUAL to the Taskfile check: list, which (A) already moved. Instrument for every number below, per [D-173]'s adopted rule: a 1-indexed awk walk of - task: <stage> entries inside the check: block — the same extraction shape check_check_wiring uses — run over Taskfile.yml at HEAD and over git show origin/main:Taskfile.yml. It yields 22 stages at HEAD (21 on main), with docs-build 16th, docs-gates 15th, release-verify-test 14th, lint-bash-version-guard-test 19th (18 on main) and audit-aud2-exitgate-test 22nd (21 on main). The four filed sites, each now naming the stage or the array instead of an ordinal: verify.yaml's "all 19 task check stages green" → "EVERY task check stage green"; aud2_exitgate_test.sh's "pass task check stage 19" → "pass this script's task check stage"; hack/audit/README.md's "at check: stage 19" → "placed in check: after task test", the load-bearing claim that sentence was actually making; the backlog's "task check across all 14 stages" → "across every stage it pins". Four more in the same files, two of them found only by the reviewer re-measuring after the first push — the substantive lesson of this section: the backlog's ORPHAN row ("the 14th of 21 check: stages" — total wrong-to-be; both ordinal and total now dropped), its BASH32-F01 row ("wired as task check stage 20", while that stage measured 18 on main — stale by two before this lane touched anything), and its AUD2-status paragraph ("audit-aud2-exitgate-test … as the 19th task check stage", "CHECK_STAGES was unchanged at 19", plus a trailing disclaimer that the array "has since moved on … to 21"). That last is the sharpest evidence for the whole approach: the disclaimer written to save two stale numbers had itself aged into a third, and this lane's own insertion made the present-tense claim wronger and walked past it. The fourth is openspec/specs/p5-redmain-changelog-integrity/spec.md's published task check # 21 stages, which this lane's change falsified; the fence was widened once the holding lanes landed, so it is fixed rather than filed. Sites deliberately left standing: hack/release/README.md:40 and hack/release/changelog_gate_test.sh say a "22nd task check stage was NOT added", which remains true as a statement about what [D-168] did — the odd reading is not a falsehood, and rewriting another lane's record to smooth a reading would be worse than the reading. Likewise p5-redmain-changelog-integrity/spec.md's non-goal ("a 22nd stage is a change to that pin"), same class, same ruling. And [D-163] above quotes "task check stage 21" inside a dated incident record: this log's rows other than this one are not this lane's to edit, which is why the claim at the head of this row is scoped to the files it owns rather than to the repository. The counts this lane got wrong itself, and the instrument defect underneath them. The first push published docs-build as "the 15th of 22" here and in the backlog — a fresh ungraded ordinal shipped inside the correction of ungraded ordinals. The second push then left hack/audit/README.md's AUD2 section calling audit-aud2-exitgate-test "the 19th" — measured 22nd, so wrong by two before this lane and by three after it, sitting 57 lines above this lane's own new sentence asserting that no ordinal is quoted in that file. Both were caught by the reviewer, not by me, and the reason is mechanical rather than careless: the surviving ordinal is split by a line wrap — the at the end of one line, 19th at the start of the next — so every grep -rn I ran, three rounds of them, was structurally incapable of returning it. A sweep that cannot match across a wrap is an instrument that cannot return the positive case, which is this repository's ENUM-LINT lesson and the exact species this lane exists to close: I was grading my own completeness with a tool that could not fail me. The sweep is now line-JOINED — each newline plus any following indentation and leading comment marker collapsed to one space before matching — run over every file in the fence, with six patterns (numeric ordinal, stage N, N stages, story/stage N of M, word-form positional, and CHECK_STAGES … at N). On this tree it returned 48 hits, every one dispositioned — the wrapped ordinal is the first line of its output — and the survivors fall in three classes only: quotations of withdrawn wording, instrumented measurements, and matches that are not stage positions at all (a bash function's 10th argument, "the second of three error returns", D-173's 54th Sonar finding). The harness is not committed, following the [D-171]/[D-173] precedent for scratch differential harnesses: its home would be hack/lint/ wired into check: and CHECK_STAGES, both outside this lane's fence, and landing it unwired is the gate invoked by nothing defect [D-124] forbids. It is a standing gate candidate — a line-joined prose-claim sweep is the one instrument that would have caught all three of this lane's own misses. It is not corrected, it is deleted: an ordinal is decoration that rots the next time a stage is inserted above it, which is exactly what docs-build did to lint-bash-version-guard-test and audit-aud2-exitgate-test. Six ordinals that were CORRECT went the same way, and the reason is the same one: aud2_exitgate_test.sh said "check stage 1" for fmt and "check stage 4" for test in five comments. Both measure right today and neither has ever moved — and both are strictly less informative than the stage's name, which is what they now say. Leaving a correct ordinal standing in the lane that removes ordinals would publish the rule and exempt itself from it. The sixth is the same claim spelled in words — aud2_exitgate_test.sh said task fmt is "the first one", which evades a numeric sweep entirely and is why the line-joined pass matches word-form positionals too; it now states the relation that was load-bearing (the tree is gofmt-clean before this gate reads it) and no position at all. Two more of the same class, in cells this lane was already editing: E11-R01's "story 12 of 14" (E11's story count is graded nowhere either) here and in the backlog, and this lane's own "D-174's insertion has since made it 19" in the BASH32-F01 row — a live ordinal published inside the note explaining why ordinals are not published. (C) GATES3-F01 — the answer to its own question is yes: assert, do not restate. hack/audit/README.md published the AUD2 control floor as "38 full, 32 text-only"; the code pinned 57 / 51, measured here rather than copied from the filing (a full run of hack/audit/aud2_exitgate_test.sh emits exactly 57 mutation controls, a --text-only run exactly 51). It stopped matching when [D-157] raised the floor twice and was stale before that. Correcting the number alone would only have reset the clock — a published number nothing grades is the [D-124] species the gate itself exists to close — so check_readme_floor now parses that one published sentence and reds when either integer disagrees with the pinned floor, in both directions: raising the floor without editing the README fails, and editing the README without raising the floor fails. Four mutation controls, chosen for branch coverage: each integer drifted in turn (the second is the exact pre-fix shape), the claim reworded out of the check's reach (found 0), and a second copy added (found 2) — both vacuity shapes, because a check that cannot locate its subject silently grades nothing. Pure text, so both modes gain them: the floor moves 57/51 → 61/55 and the README publishes the new pair. Same file, same species, deflated in the same pass: the floor comment quoted "roughly forty distinct findings and thirty-eight of them carry a control", two ungraded and long-stale counts; the claim they decorated stands without them. (D) BACKLOG-TRUTH — [D-166] read before editing, not summarised at second hand. D-166 withdrew ADR-0002's published claim that the rego tier is an "escape hatch for cross-entry checks, complex derivations, whole-branch conventions": [D-156] had struck all three clauses and the sentence stayed published for eleven days. The bullet now states the two shapes D-156 measured — folds/aggregates over an in-input collection, and unbounded graph reachability over an in-input adjacency — with Amendment 1 recording the withdrawn sentence verbatim and why each clause failed (cross-entry/cross-manifest and whole-branch are input-availability limits the Rego tier inherits unchanged; "complex derivations" is the named-intermediate shape, struck because inlining is semantically identical). Verified against docs/adr/0002-* before either row was touched. D-166 itself listed both survivors under "LEFT DELIBERATELY, with reason" because the backlog belonged to another lane in flight; they are reconciled here. E11-R01 read OPEN and still called the ADR wrong → DONE (D-166), closed by the residual and not by E11-S12 which nominally owned it (a late story of an epic that may never run, and its Test: list omitted the actual wrong file). What D-166 expressly did not close now has its own OPEN row, E11-R02, rather than a paragraph inside a DONE one: nothing machine-consumes a REQ's Test: list, and hack/docs/truthlag_pins_test.sh compares ADR Status rows against docs/adr/README.md and never looks at body content — so a regression re-inserting the withdrawn sentence still fails nothing; the pin belongs in hack/docs/, outside both D-166's fence and this one's. The first push left it inside the flipped row, which is the anti-pattern [D-171] states in this repository's own words — a residual mentioned only inside a row that has just flipped to DONE leaves tracking with it — and which D-166 had recorded about E11-R01 itself. Committing that error while closing E11-R01 is worth the ink: this failure species survives being written down, so it needs a slot, not a sentence. E11-S01 — the more misleading of the two, because it restated the withdrawn wording inside a row already marked ✅ DONE (D-156), where a reader has no cue anything is outstanding — said, verbatim, "a CEL expression's iteration count cannot be data-dependent (depth is syntactic, capped at cel-go's 250), so unbounded reachability has no spelling" — quoted in full in the row rather than elided, following D-166's own precedent. D-166's F4-03 measured its first clause false: a comprehension over an input collection costs |N|, and |N| is data. Corrected to the claim the record needs — the number of nested iteration levels is syntactic, fixed by the expression text — and amended in place with the withdrawn wording quoted, following D-166's own precedent for D-156's row. No verdict moves; only the reason one rests on does. Not asserted: that no further copy of any of these claims survives. Four of the eight surfaced only because the array moved under them or because a reviewer re-measured, and one of those four was this lane's own — which is the argument for number-free formulations rather than for a better sweep. What IS asserted, precisely, because the looser version of this sentence was false twice: after a line-joined sweep of every file in the fence, no task check stage ordinal or stage count survives in them as a standing claim. Numbers do survive in three shapes, all deliberate: verbatim quotations of withdrawn wording (each one paired with the measurement that withdrew it); dated records — the gate matrix below, and "21 → 22" as a record of a change; and the instrumented measurement block above, which quotes ordinals only alongside the instrument that produced them, per [D-173]'s rule. Rows of this log other than this one are not this lane's to edit, and non-stage ordinals (a bash function's 10th argument, "the second of three error returns", D-173's 54th Sonar finding) are outside the class entirely — the sweep returns them and they are dispositioned, not silenced. Gate matrix green, re-run on the finished tree: task check EXIT=0, task coverage 91.1% against the 91% floor. Revert: drop - task: docs-build and its CHECK_STAGES entry together (republishing a nav gate with no local evidence), and restore the nine stale counts (republishing 19/14/20 against an array that has moved twice since).
D-176 2026-09-05 AGENTS.md enters the tree; DOC-03's acceptance rested on a premise that was false, and is withdrawn. D-132 accepted DOC-03 with the rationale, quoted here as withdrawn: "the stale operator-local AGENTS.md is globally gitignored — not in the tree, so no in-tree gate can reach it". The first clause was never true. Measured against the tree as it stood at 9ab6e36, immediately before this commit: git check-ignore -v AGENTS.md exits 1 with no matching pattern (still true now); git config --get core.excludesfile is empty at all three scopes (still true now); and git log --all -- AGENTS.md was empty — no commit on any branch had ever touched it. That third probe is recorded as the pre-commit observation it was: this very commit falsifies it, and a probe published in the present tense inside the change that makes it false is not evidence. The file was not ignored by anything; it was merely untracked. The mechanism that hid it is the exact inverse of what a "gitignored" story implies, and it is worse: git status shows untracked files and hides ignored ones, so ?? AGENTS.md was printed on every git status run in this repo for over a month — the file's mtime is 2026-07-27 and its E1–E9 claim went false on 2026-07-29, when bb9786b decomposed E1 into 8 INVEST stories. Nothing concealed the file. D-132's clause was written from inference where a one-line probe was available — this repo's own recurring failure species (D-157/D-160/D-164/D-165), sitting inside the disposition table built to catch it. The practical consequence is worse than the finding stated: the seven hard rules the tree cites by number — 27 occurrences across 13 files at 9ab6e36, from `git grep -o 'AGENTS.md rule' 9ab6e36
D-177 2026-09-05 D-150 is implemented: CI-based SonarCloud analysis replaces Automatic Analysis, and a gate now pins it in place. D-150 (2026-08-16) decided to adopt a checked-in sonar-project.properties and accept the loss of Automatic Analysis. It was never implemented — git log --all -- sonar-project.properties was empty on every branch and grep -rl sonar .github/ returned nothing, so the decision sat for three weeks with a Revert: clause and no implementation. Worse, it was decided against an instrument that had already stopped. D-150 reasons from a snapshot dated 2026-08-03; SonarCloud's last analysis is 2026-08-04T08:01:09Z. Cause, established and confirmed by the operator: Automatic Analysis was deliberately disabled on/around 2026-08-04 in favour of CI-based analysis, and the scanner half of that migration was never built — leaving the project with neither. Corroborating timeline, each measured: 19 analyses ran that morning and then none ever again — the project's whole history is 25, so three quarters of it is that single day (api/project_analyses/search?ps=100, grouped by date; an earlier draft of this row said "eight", which was the PAGE SIZE of a ps=8 query read as a result — the query answered a question about itself); SONAR_TOKEN was added to repo secrets at 2026-08-04T08:30:33Z, 29 minutes after the last one (gh secret list); no properties file and no scanner step ever existed. So from 2026-08-04 to 2026-09-05 this repository had no Sonar analysis of any kind, while four backlog rows kept citing figures frozen at that instant — and two of them (SONAR-SHELL, SONAR-GO-MISC) were separately discovered to have been stale on the day they were written, each costing a lane. A competing explanation is withdrawn here rather than left in a thread: this session first published that the silence was "almost certainly" caused by the assent → Assent repo rename, on the strength of a confirmed redirect. That is wrong, and it is the same species as the rest — a redirect was confirmed to exist and allowed to stand in for causation. The refuting instrument was available throughout: api/components/show?component=PlatformRelay_assent returns "name":"Assent", so the ALM binding had tracked the rename and was never detached; the capitalised key PlatformRelay_Assent returns not found, so no second project exists either. DONE: (a) sonar-project.properties is checked in, carrying the go:S3776 × **/*_test.go exemption D-150 was written for — the exclusion Automatic Analysis cannot express and the only reason the file exists; (b) a SHA-pinned SonarSource/sonarqube-scan-action step runs in verify.yaml after the coverage gate, because sonar.go.coverage.reportPaths reads the cov.out that task coverage writes, and the reverse order leaves that path pointing at a file that does not yet exist. The consequence is reasoned, not measured: a missing coverage report is documented scanner behaviour as a warning rather than an error, so coverage would go silently unreported instead of failing — this row does not claim to have run it, and the ordering pin is worth keeping either way; (c) hack/lint/workflow_pins_test.sh gains check_sonar_scan_wired and check_sonar_properties — 15 assertion sites graded by 15 mutation controls, each red for its own stated reason, plus 2 green checks (17 OK: lines, counted from the run rather than from the source). They cover step deletion, an unpinned tag, continue-on-error, a constant if:, the coverage ordering, a re-cased project key, a drifted coverage path, a dropped S3776 exemption, and — added after independent review measured them walking straight past the first draft — the G5-02 key spellings if : , "if":, a trailing <CR>, continue-on-error : , "continue-on-error":, an unnormalisable merge key, and a block-scalar if: — if: >- with false on the next line, which independent review found still walking past the hardened reader because the value is not on the key line at all. It is refused through the same ?unreadable route rather than guessed at. The first draft of that gate was itself fail-open, and the fix is the substance of this row. It read if: and continue-on-error: with fresh literal patterns; independent review built the mutants and measured five spellings returning GREEN while disarming the step for a conformant parser — if : ${{ false }}, "if": false, if: ${{ false }}<CR>, continue-on-error : true, "continue-on-error": true. That is G5-02, already documented and already solved in hack/lib/pr_reach.sh, which workflow_pins_test.sh already sources: _assent_step_keys normalises key names and emits ?unreadable for anything it cannot reduce, which callers refuse. Writing a sixth literal reader beside a shared normalising one is exactly what D-157 exists to stop. The check now uses the shared reader, grades if: by NORMALISED VALUE and refuses an unreadable key outright, and all six shapes are pinned as controls so the regression cannot return quietly. That gate is the point of this row. Wiring an analyser back in with nothing asserting it stays wired would re-earn the outcome being fixed: what failed here was never the analysis, it was that its absence was invisible. The project key stays the lowercase PlatformRelay_assent deliberately, despite the repo now being Assent: SonarCloud keys are immutable and independent of the GitHub name, and "correcting" it would create a second project and orphan every historical measurement — pinned, with a mutation control, for exactly that reason. Two limits, stated rather than papered over. (1) The scan is not a required check — that is live branch-protection config and unreachable from in-tree, the same class as RELSE-08; a failed or missing scan is therefore visible red on a PR, not blocking. Filed as AUD-SONAR-REQUIRED. (2) Fork PRs carry no SONAR_TOKEN, so the step is skipped there rather than hard-failing; that is a real coverage gap, and it must be settled before the check is made required or every fork PR blocks. Measured outcome, added after the lane's own CI run rather than predicted: the scan step reported success and SonarCloud produced an analysis — api/project_pull_requests/list shows PR 125 analysed at 2026-09-05T20:34:44Z against commit 703dc1d. But main's branch analysis did NOT move and will not until this merges: a PR analysis is stored against the PR, so api/components/show and api/project_analyses/search?branch=main still report 2026-08-04T08:01:09Z. Those two endpoints answer a question about main, not about whether the scanner ran — a distinction worth writing down, because checking the wrong one reads as "still broken" when analysis has in fact resumed. The PR gate is ERROR, and it is ONE condition rather than a summary judgement: api/qualitygates/project_status returns four conditions OK and exactly one ERROR — new_security_rating actual=3 against a threshold of 1. new_maintainability_rating is 1 (A), so the 21 code smells do not fail the gate and never did; new_reliability_rating is 1, new_duplicated_lines_density 0.0, new_security_hotspots_reviewed 100.0. Reading "3 vulnerabilities, 21 code smells" off the PR-list endpoint and inferring that the smells caused the red is reading a summary, not the gate. That single condition is an artifact of the stale baseline. The CONCLUSION is measured; an earlier draft of this row stated the MECHANISM wrongly and badged the wrong version 'measured, not asserted' — independent review refuted it, and the correction is kept here rather than silently swapped. The mechanism that does hold: a PR analysis reports issues in the files the PR TOUCHES, and an issue counts as new when it has no counterpart in the base branch's STORED ISSUE SET. Every one of the 27 reported records sits in .github/workflows/verify.yaml or hack/lint/workflow_pins_test.sh, and both are PR-touched; main's stored set holds 0 unresolved issues for either file (api/issues/search?branch=main&resolved=false&componentKeys=… — the resolved=false is load-bearing and was missing from an earlier citation of this row: without it the same query returns 2 for verify.yaml, two CLOSED githubactions:S7637 that no longer exist in the code, and the published figure does not reproduce). So nothing there can match and everything found is new. The half of this mechanism PR 125 does NOT exercise, stated rather than glossed: nothing here measures the converse — an issue WITH a counterpart being suppressed — because no PR-touched file carries an unresolved issue on main at all. Independent review offered the two closed S7637 records as evidence for that half; they are declined as an instrument, because a CLOSED issue means the finding is gone from the code, so its absence from the PR is explained without suppression and would prove nothing. It holds 0 because main has not been analysed since 49142c7 (2026-08-04T08:00:49Z), at which commit hack/lint/workflow_pins_test.sh did not yet exist and .github/workflows/verify.yaml was 42 lines against 246 today — the reported lines were not there to be seen. The dated proof: the three githubactions:S8545 findings sit on verify.yaml lines 124, 225 and 231, blamed to 2c0d8bb, ba77fc4 and e8528fa — all three authored 2026-08-08, four days after the baseline commit — and api/issues/search?branch=main&rules=githubactions:S8545 returns 0. The refuted mechanism, recorded so it is not re-derived: the row previously said new code is "computed against the base branch's last analysis" so that "a month of main's OWN commits falls inside this PR's new-code window". Two measurements kill it — api/measures/component?pullRequest=125&metricKeys=new_lines returns 361, the scale of this PR's own diff (git diff --numstat 481b455 703dc1d = 431 added lines) and not of the 77,978 added lines across 745 files since 49142c7; and 14 files carrying go:S3776 on main were modified since that baseline, so under the refuted mechanism their issues would be in the window, yet PR 125 reports zero go: issues. Nor is the window merely the PR's changed lines, which is the other obvious guess and is equally wrong: the PR's only edit to verify.yaml is the added block at lines 134–156, and 124/225/231 are unchanged context — git diff -U0 481b455 703dc1d shows exactly one +134,23 hunk. Untouched lines are reported because the FILE is touched and the base set is empty, which is precisely why re-baselining main fixes it. They are the go install …@"${TASK_VERSION}" and @"${GOLANGCI_LINT_VERSION}" pins that AUD-S09/SEC-04 put there deliberately: a rule disagreeing with a documented decision, on lines this lane never touched. Blame attribution at the analysed head 703dc1d, re-run after independent review caught an earlier draft of this row publishing figures from a superseded commit: api/issues/search?pullRequest=125 returns 27 records — 24 OPEN (21 CODE_SMELL + 3 VULNERABILITY) and 3 CLOSED. Every one of the 24 open issues blames to a commit predating this lane's merge base: 0 lane-introduced, 24 pre-existing, checked against the nine commits the lane carried at that revision (an earlier draft of this row said five). The 3 closed ones ARE this lane's own, and are recorded here rather than quietly dropped: 1e8de83 — the commit that closed the disarm fail-open — introduced two shelldre:S7679 and one shelldre:S1192, and fa7034f removed them, which is the entire content of that commit's ", and stop adding smells". SonarCloud returns closed issues with a null line, so a blame pass keyed on line alone drops them without noticing; they were recovered from textRange (657, 679, 1946) and blamed at the pre-fix head. Why these figures do not go stale on the next commit, which is the trap this row already fell into once: the paragraph is pinned to 703dc1d because that is what SonarCloud analysed, but every commit after it so far touches only docs/decisions/decisions.md and CHANGELOG.md, while every reported issue lives in .github/workflows/verify.yaml or hack/lint/workflow_pins_test.sh — the intersection is empty (comm -12 over git diff --name-only 703dc1d..HEAD and the issue components). The count is deliberately not stated: an earlier draft said "the two commits after it" and was wrong by the next push. The range form re-evaluates; a tally does not. The re-analysis at fb2137e confirms it: same gate, same lone new_security_rating actual=3, same 24 open + 3 closed. So the figures are invariant across doc-only commits, and this row states the head it was measured at rather than chasing HEAD — a claim about "the current head" restales itself on every commit, INCLUDING the commit that fixes it. Merging re-baselines main, and the next PR's new-code window becomes real. The new_security_rating condition is expected to clear on the following PR once main carries a scanner analysis dated after 2026-08-08 — that is the stated mechanism, not a measured result, and this row does not claim to have observed it. Anyone meeting an ERROR gate in the interim should blame the reported lines before treating it as a regression; the command is git blame -L <line>,<line> against the lane's own commit range, and closed issues must be picked up from textRange when line is null. Every SONAR-* count in the backlog stays untrustworthy until re-measured against a post-merge main analysis — the PR-scoped one does not refresh them. SONAR-GO-CX-TEST closes by exemption rather than by refactoring passing test files for a non-gating metric. The figures, measured live on 2026-09-05 via api/issues/search?componentKeys=PlatformRelay_assent&rules=go:S3776&resolved=false: 61 open go:S3776, of which 45 sit in *_test.go across 38 files and 16 elsewhere across 13 files — the latter stay in scope for SONAR-GO-CX-PROD, which this exemption deliberately does not touch. An earlier draft of this lane published "34 hits" in two files; D-150's 34 is a count of files, not issues, and neither figure survives re-measurement. These numbers are dated because they will move: they come from the 2026-08-04 analysis, the last one before a month of unscanned commits. Revert: delete sonar-project.properties, the scanner step and both gate functions together — Automatic Analysis does not resume on its own, it must also be re-enabled in the SonarCloud UI, which is precisely the split that produced the month of silence.
D-178 2026-09-10 Dependabot PRs are the SECOND named SonarCloud coverage gap, beside fork PRs: the D-177 scan step is now skipped when the run's github.actor is dependabot[bot]. Operator-approved 2026-09-10 (option A of the INBOX decision). Symptom: every Dependabot PR redded verify at the step SonarCloud analysis (D-150/D-177 …) — runs triggered by dependabot[bot] receive no Actions secrets, so SONAR_TOKEN was empty and the scanner failed (job log 101914500641 on PR #127). D-177's fork-PR guard (head.repo.full_name == github.repository) does not cover it: a Dependabot branch lives in this repo. Change: the step's if: becomes `github.event_name != 'pull_request'
D-179 2026-09-10 Every sort / comm / join / uniq in a hack/** script runs LC_ALL=C at the call site, because task check was red on a clean main for a developer with a UTF-8 locale and green on CI (LOCALE-F01). Symptom, reproduced on clean origin/main at e556a45 before anything was changed: with LANG=en_US.UTF-8, bash hack/release/changelog_gate_test.sh exits 1 in §7a with comm: file 1 is not in sorted order; with LC_ALL=C the same tree exits 0. CI never saw it: GitHub's runners use C.UTF-8, whose collation is code-point order. Mechanism, measured: the host is Ubuntu 26.04, whose coreutils are uutils 0.8.0 — its sort collates by LC_COLLATE (en_US ignores punctuation and case at the first level, so lane/e10-… sorts before lane-e9-…) while its comm checks order bytewise. GNU coreutils honour LC_COLLATE in both, so that exact pipeline is consistent there — but GNU has the mirror-image hazard, and it was already in the tree: hack/docs/truthlag_pins_test.sh fed LC_ALL=C sort output into an unpinned comm. One pin on every call is the only spelling that is right on both. Scope — grepped, not guessed: 36 unpinned ordering calls in nine scripts (changelog_gate_test.sh 12, audit/exitgate_test.sh 8 incl. a comm, truthlag_pins_test.sh 2 comm, aud2_exitgate_test.sh 3, bash_version_guard_test.sh 3, validate-schemas-stock.sh 3, depguard_test.sh 2, install_cosign_pin_test.sh 2, workflow_pins_test.sh 1); all now LC_ALL=C. Glob expansion order and [[ < ]] also follow LC_COLLATE; measured, no check stage compares a glob's order and no script compares strings with [[ < ]] — stated in the gate header as a measurement, not a gate. Why per-call, not export LC_ALL=C per script: an export leaks into every child. hack/audit/exitgate_test.sh runs a whole task check, so exporting C there would run every stage beneath it under C and mask a missing pin in any of them; it would also move LC_CTYPE to ASCII for every other command in the script. The per-call form pins exactly the comparisons, and was already the majority spelling in the tree. Why LC_ALL not LC_COLLATE: an exported LC_ALL overrides LC_COLLATE. Why C not C.UTF-8: macOS has no C.UTF-8. Gate: new hack/lint/locale_pin_test.sh, a task check stage (lint-locale-pin-test, in CHECK_STAGES and STAGE_BODY_PINS) AND a step in the PR-visible verify job, because the runner's locale hides the defect and task check reaches CI only on push. (1) every command-position call in hack/**/*.sh carries LC_ALL=C; (2) population controls — the scan must see the comm sites that motivated it, so a pattern typo cannot pass by matching nothing; (3) mutation controls — real copies of changelog_gate_test.sh (comm and sort), truthlag_pins_test.sh and example_format_inventory_test.sh with the pin stripped are each flagged, 13 call shapes × 4 commands are flagged unpinned and clean pinned (incl. LC_COLLATE=C and LC_ALL=C.UTF-8 as non-pins), and look-alikes (comments, a variable named uniq, the word in strings) stay silent; (4) a behavioural control that picks an installed UTF-8 locale with non-byte collation and shows, over the real git log --merges subjects §7a compares, that the fully pinned sort→comm pipeline is correct while at least one pin-removed variant misbehaves on the running host (here: 2 of 3). It skips LOUDLY without such a locale, and on CI (GITHUB_ACTIONS set) that skip is a FAILURE. One exemption marker exists, usable only inside the gate itself, with its count pinned at 4. Mutation evidence outside the gate: stripping the pins from changelog_gate_test.sh reproduces the original red under en_US.UTF-8 (comm: file 1 is not in sorted order, rc 1) and makes locale_pin_test.sh name 11 of its lines; restoring them is green. What CI cannot show, stated plainly: GitHub's runners carry GNU coreutils, on which the uutils shape (both calls unpinned) happens to be consistent — so the behavioural control there proves the GNU shape, and the text scan is what makes the rule platform-independent. Precedent for recording a gate-hardening fix as a D-row: [D-154] (BASH32-F01).
D-180 2026-09-10 CHANGELOG.md commits RELEASED versions only; the ## Unreleased section is never committed, so an ordinary commit can no longer make the drift gate red. SUPERSEDES D-125's lane-regeneration working rule and its "push-to-main is the backstop" for ordinary drift, D-163's standing remedy, and — as amendments recorded in the specs themselves — REQ-AUD-S02-01's "post-tag commits under Unreleased", AUD-S02's acceptance criterion "a commit that does not regenerate the changelog makes task check fail" (now: ordinary commits stay green; a hand edit, a history-re-rendering cliff.toml change or an unstamped tag fail), REQ-E9-S03-02's "includes Unreleased", and the E9-S03 goal "seed with Unreleased"; the AUD2 spec's "regenerate after the final rebase" note is annotated historical. Operator-requested 2026-09-10 ("a sustainable fix"), design delegated. Problem: hack/release/verify-changelog.sh diffed the WHOLE render — Unreleased included — against the committed file, so every changelog-relevant commit made main stale until a regeneration commit landed. Lanes absorbed that with a regenerate-then-amend-to-a-fixed-point step. Dependabot cannot: every bot merge reddened main's push run and needed a hand-made regen PR (#131, #132 the same day). D-163 had parked two mechanisms for this (auto-regenerating on bot branches, or tolerating bot drift in the gate), and both were unattractive: the first needs a write token on a bot branch, the second is a fail-open. Decision: the committed file is the header (compatibility notes), one ## [x.y.z] section per tag, and the footer — a function of the TAGS and cliff.toml, not of HEAD. Mechanism: one cliff.toml, one switch. The body template renders nothing for the unreleased release when ASSENT_CHANGELOG_RELEASED_ONLY=1, and the new hack/release/render-changelog.sh is the only thing that sets it. It is the single renderer behind both task changelog-write and verify-changelog.sh, so the writer and the checker cannot disagree. Everything else renders everything BY DEFAULT: task changelog (which also env -us the switch), release.yaml's git-cliff --latest, and the §6/§7/§8 checks in changelog_gate_test.sh through a render_full helper that clears it. So a forgotten switch fails CLOSED — an ## Unreleased block reaches the drift gate and reds — instead of hiding unreleased entries from a detector. Chosen over a second config file, which would be two sources of truth for grouping and headers, and over a git-cliff flag, because git-cliff 2.13.1 has none that drops only the unreleased release. Measured: the default render is byte-identical to the pre-D-180 render and --latest is byte-identical; the released-only render is the old render minus exactly the Unreleased block. The drift gate now reds on exactly three causes: a hand edit, a cliff.toml change that re-renders released history, and a pushed tag whose section has not been stamped yet. Release procedure: after tagging, task changelog-write + :memo: chore(release): stamp the vX.Y.Z section after tagging ADDS the new section (the v0.3.0/v0.4.0 stamp commits are the precedent, now with the cliff-skipped subject). Until that commit lands, changelog-verify is red for everyone. That is deliberate: the stamp cannot be forgotten. The Release body is unaffected because it is rendered from the tag. The order is load-bearing (review F2): release.yaml's verify-green tag gate (REQ-AUD-S03-01) needs EVERY verify run on the tagged SHA completed green, and any verify run that checks out the tagged commit after the tag exists renders the new section and reds, which then locks the tag out of release re-runs and workflow_dispatch rebuilds. So: (1) wait until every verify run on the commit to be tagged has completed green; (2) tag and push; (3) land the stamp promptly — before the next scheduled verify run (Monday 06:00 UTC); (4) never re-run a verify run on the tagged SHA after the tag exists. The window is not new — before D-180 the same tag turned the committed Unreleased block into a missing ## [x.y.z] section — but it is now written down in hack/release/README.md rather than left to discovery. Closing it mechanically is left open, not decided here. The v0.4.0 stamp commit used :memo: docs(changelog):, which cliff.toml does not skip, so it will appear under Documentation in v0.5.0's notes; future stamps use the skipped :memo: chore(release): prefix. What of the older rows still stands: D-125 — the gate in task check and on push-to-main + schedule, the pull_request guard, the Keep-a-Changelog headings, the compatibility notes living in cliff.toml's header, and the D-124 wiring closure. What is RETIRED is its lane working rule (regenerate after the last content commit and after every git merge origin/main) and push-to-main as the backstop for ordinary drift, because ordinary drift no longer exists. The :memo: chore(release): / :wrench: chore(release): skip rule stays, but it now matters only for stamp commits and other cliff.toml-driven regenerations, where it keeps the stamp out of the next release's notes. D-136 — the structural merge-skip parser stands in full; its "regenerate after a merge" clause is retired with D-125's. D-148 / OQ-30 — the pull_request guard on the CI step is unchanged. D-180 changes no CI gating. Its premise may be weaker now that HEAD-ordered unreleased content is out of the diff, but that is unmeasured, and re-opening OQ-30 would need its own evidence. D-163 — its analysis stands; its standing remedy ("heal main with a regen commit") and its parked (i)/(ii) are superseded by this third option. Cost, stated plainly: CHANGELOG.md on main no longer shows unreleased changes — task changelog does — and PR diffs no longer show changelog lines, so the only pre-release view of the next Release page is the preview and the gates. The detectors still inspect the with-unreleased render, so malformed subjects are still caught before a release. Gate, both polarities (changelog_gate_test.sh): §1 now REQUIRES the committed file to have no ## Unreleased and its newest section to equal the newest reachable tag. §2 pins changelog-write and verify-changelog.sh to render-changelog.sh, the switch to render-changelog.sh's actual git-cliff invocation line (not the token, which its header comment also carries — review F4; §2b deletes that one line with the comment left in place, and also sets it to 0, and both red), and the preview to --unreleased + env -u. §6 shows the Release body identical with and without the switch. New §10 runs the REAL scripts in a clone of this repository, with the working-tree files overlaid: (10a) three changelog-relevant commits after HEAD — a feature, a fix and a Dependabot-shaped bump, each positive-controlled to render in the preview — leave the gate GREEN. The pre-D-180 mutant (the switch set to 0, regenerated and committed under the old rule) goes RED on the next ordinary commit, reproducing the churn. That GREEN row would have been red before this change. (10b) RED on a hand edit and on a cliff.toml group rename, each checked to red for drift and to carry the expected diff line. (10d) an unreleased literal-emoji subject is flagged by §8's detector on render_full, is invisible in the committed form (the mutation that shows the input choice is load-bearing), stays flagged with the switch exported, and does not red the drift gate. (10c) a tag without its stamp is RED naming ## [99.0.0]; release.yaml's own args render the tag's body correctly BEFORE the stamp; the stamp commit adds the section and is green with no amend; its subject stays out of the next preview; the next ordinary commit stays green. Extra mutations outside the file: dropping env -u from render_full reds §10d, and restoring the pre-D-180 cliff.toml reds §4. Transition: one commit regenerated CHANGELOG.md under the new rule (it dropped the 14-line Unreleased section; main was at v0.4.0 + 11 commits). The commit after it needed no regeneration or amend: task changelog-write produced no diff and task changelog-verify stayed ok. REQ-E9-S03-02 and hack/release/changelog_test.sh are amended to match (released sections, no Unreleased). Revert: drop the template condition and render-changelog.sh, point changelog-write back at a bare git-cliff -o, and regenerate — that restores the churn.
D-181 2026-09-10 The changelog drift gate tolerates exactly one drift: HEAD is itself a release tag and CHANGELOG.md lacks only that tag's section. Closes the lockout window D-180 left open. Finding (D-180's own review, recorded there as the open F2 proposal): between git push origin vX.Y.Z and the stamp commit, any verify run that checks out the tagged SHA — a re-run, or the weekly schedule on an unchanged main — rendered the new section, found it missing and went red; release.yaml's verify-green tag gate (REQ-AUD-S03-01) requires EVERY verify run on the SHA to be green, so one such red locked the tag out of its release job, re-runs and workflow_dispatch rebuilds. The only defence was a written rule ("never re-run verify on the tagged SHA; stamp before Monday 06:00 UTC"). Decision: verify-changelog.sh, after the normal diff fails, collects the v[0-9]* tags pointing AT HEAD (never tags further back) and renders the committed form again through render-changelog.sh with --ignore-tags for exactly those tags — the same bytes the tagged commit was green with before the tag existed. Equal → exit 0 with a notice naming the stamp commit to land (a ::warning:: annotation under GITHUB_ACTIONS); otherwise the original diff and red. render-changelog.sh forwards extra arguments to git-cliff for this; it stays the one definition of the committed form. Still red: a hand edit or a history-re-rendering cliff.toml change on the tagged HEAD, an unstamped tag with ANY commit on top (so the stamp must be the next commit on main), and an older unstamped tag behind a newer tagged HEAD. Every other check on the tagged commit follows the same allowance (review F1): CI runs the whole of task check in release-exitgate (hack/audit/exitgate_test.sh), so a red anywhere in it on the tag SHA locks the tag out exactly as the drift gate did. Measured on a freshly tagged clone, stage by stage, the only other red was changelog_gate_test.sh — §1 (newest section = newest tag) and the §10 sandbox baseline, whose overlay commit sat on top of the tag. §1 now reads verify-changelog.sh's own allowance from its output (not a second implementation) and warns instead of failing on that commit; the §10 sandbox drops HEAD tags whose section is missing, starting from the pre-tag state. The stamp therefore cannot be forgotten because the first commit on top of an unstamped tag is red everywhere, not because the tagged commit is. Empty releases (review N2): a tag whose commits are all cliff-skipped (e.g. a tooling-only :memo: chore(release): patch) renders no section, so there is nothing to stamp; §1 accepts it when verify-changelog.sh is plainly green, which means the file is byte-identical to the released-only render of every tag and therefore cannot hide a missing stamp. §10e's overlay commit uses a rendering subject for the same reason — with a skipped one, v98.0.0 was an empty release right after every stamp commit and main went red (review N1). Proof: §10c at both polarities (tagged HEAD green; hand edit, re-render, commit on top, older unstamped tag behind a tagged HEAD red; a release and its -rc on one commit green with the hint naming the release; an older v99x0x0 that v99.0.0 matches as a bare regex red), and §10e runs this whole script on a tagged-but-unstamped clone (green) and with a commit on top (red), and grades the stamp commit and an empty release through §1's function. Six mutants each red the suite: no allowance; an allowance that skips the pre-tag comparison; one keyed on the newest reachable tag instead of HEAD's; no regex escaping; §1 without the exception; the §10 sandbox without the rewind. Amends REQ-E9-S03-03, REQ-AUD-S02-01 and AUD-S02 criterion #2 in their specs; hack/release/README.md "Cutting a release" drops the never-re-run and before-Monday rules.
D-182 2026-09-25 mr.labels is WIRED through the live assent run path rather than retired by a lint hard error. The frozen predicate-scope contract keeps its label field, and the fail-open is closed by making the forge fetch honest. The six-leg review found mr.labels schema-carried (evaluation-input.schema.json), engine-bound (aggregate/evaluate.go binds labels) and harness-tested (internal/adoptertest/mr_test.go) but never fetched on the live path: cmd/assent/run.go:581 mrFrom built aggregate.MR with no Labels, and the GitLab adapter's two MR decoders (GetMR in internal/forge/gitlab/gitlab.go and mrWithAuthor in snapshot.go) ignored the API's labels array. Consequence, and why it is a safety defect not a missing feature: every label predicate in production evaluated against an empty list, so a NEGATIVE guard (!('security-hold' in mr.labels)) was vacuously true and a security-hold MR could APPROVE and merge. Decision — the alternative is recorded and rejected: a lint hard error rejecting any mr.labels reference plus a predicate-scope.md amendment would have made the contract truthful by deleting a capability adopters were already sold; wiring keeps the contract and costs two JSON decodes. Implementation: forge.MRInfo gains Labels; GetMR and mrWithAuthor decode the string array (absent field → nil, never an error); Snapshot surfaces them on forge.MRHeads.Labels; mrFrom copies info.Labels into aggregate.MR.Labels. Proven, not asserted: a run-path test through the real GitLab adapter over httptest (fakeGitLab) shows a labelled MR reaching the engine (a label-proven obligation APPROVEs with the label, REVIEWs without it) and the negative-guard polarity failing CLOSED (the guarded label yields BLOCK with zero approve/merge writes). Spec: openspec/specs/p5-e4-gitlab-forge/spec.md REQ-E4-S02-05 + REQ-E4-S06-08. Revert: drop Labels from MRInfo/MRHeads, the two decodes and the mrFrom copy — reopens the empty-label fail-open for every negative guard.
D-183 2026-09-25 The six-review round's first fix slice lands: assent run reads every judged byte at the pinned commit SHA, not the mutable branch name (U-04 item 1). Origin: the 2026-09-25 six-leg review (data/assent-unify/report.md §4.1 U-04, §6 lane B1; data/assent-diff-qfn/report.md QFN-01) at HEAD b8123cc. The defect, stated precisely: orchestrate pins info.SourceSHA/info.TargetSHA from GetMR (run.go:177), and the approval and merge?sha= CAS are pinned to those commits — but the six content reads on the live path resolved a mutable branch name (run.go:203,211,230,249,270 = info.TargetBranch; :274 = info.SourceBranch). The invariant the merge leans on, final head == pin ⇒ final head == what was judged, is therefore false: a contributor with push rights on the MR branch pushes benign X2 (judged), then force-pushes back to pinned X1 before Reconcile's re-read, the SHA-guard passes (head == pin), and the forge merges X1 — bytes never evaluated. The DecisionRecord pins policySha/sourceSha/targetSha but no content digest, so the record cannot distinguish the states either; ADR-0015 §2's "Merge is SHA-guarded (no TOCTOU)" heading describes the branch-moved guard, not a bytes-equal-to-pin guarantee. The change: pass info.TargetSHA at :203, :211, :230, :249, :270 and info.SourceSHA at :274. The GitLab adapter already accepts a commit SHA as ?ref= (gitlab.go:474, the same URL-escaped parameter), so this is one argument at six sites — no adapter API change, no new discriminator, no reordering. Target-side reads are pinned too (judgment call (a)): not strictly required for the source-side attack, but it removes the maintainer-push-window ambiguity on the policy/config/pack loads and leaves one uniform rule rather than an asymmetry a later reader must re-justify. The tests are FLIPPED, not merely updated (judgment call (b)): the run-path fake's file router now serves the pinned SHAs and errors on any other ref, and TestFileAtRef asserts the SHA it sends — so a regression to info.TargetBranch/info.SourceBranch reddens the existing suite rather than passing against a fake that still accepts branch names. run_self_vouch_test.go:70's policy-load ref assertion moves from f.target to f.targetTip for the same reason. The move-and-restore case is in two places, deliberately (judgment call (c)): the internal/forge/conformance case sha-guard-source-moved-and-restored (with a new Fixture.MoveSourceHead seam and a catalog.yaml row) drives forge.Reconcile only, so it proves the CAS merges once the head is back at the pin — i.e. the CAS alone cannot catch a move-and-restore, which is exactly why the read must be pinned; the run-path case TestRunJudgesPinnedSHAWhenBranchMoves proves the read itself follows the pin (branch points at benign bytes, pin at violating bytes, decision follows the pin). Strictly stronger pinning, no behaviour change beyond it: the same bytes are read when the branch has not moved. Explicitly NOT in this lane: U-04 items 2–4 (SHA-bound /repository/compare enumeration re-fold — shares its mechanism with the D-139 trio, lane B5; the CI_MERGE_REQUEST_SOURCE_BRANCH_SHA belt; the additive headContentSha pin, lane B9), the --checkout tree↔SHA binding (U-08/QFN-04, lane B5), the forgePort shape (E10), and the docs/usage/cli.md "runs without --checkout are not exposed" claim (its enumeration half is item 2). Spec: openspec/specs/p5-rev1-pinned-sha/spec.md. Revert: restore info.TargetBranch/info.SourceBranch at the six sites and un-flip the fake routers — which re-opens the move-and-restore merge of un-evaluated bytes on the checkout-less path the docs call safe.
D-184 2026-09-25 RVW-S01 — an empty/absent binding require: must never arm APPROVE: the run path refuses it, assent lint hard-errors binding-require-empty, and this row supersedes D-021's "vacuously covered" clause. The frozen schema's minItems: 1 is deferred. Finding (reproduced on the run path): a schema-valid RulesetBinding binding with require: [] (or no require: key) decides APPROVE with zero findings for any governed change its block rules do not fire on. The obligation layer iterates bind.Require, so an empty list makes it vacuous; with the forge-probed arming preconditions met the run then approves and merges. The schema accepts it (require is not in the binding's required list and carries no minItems; schemas/policy/v1alpha1/ruleset-binding.schema.json:52-57) and lint passed it (checkObligationCoverage iterates the same empty list), while two in-repo authorities say the opposite: GUIDELINES.md §Safety-1 ("Every change must be positively vouched") and internal/core/decision/record.go's own seam note (S03 review F3: the CLI wiring "must guarantee require is non-empty before an APPROVE is armed"). D-021 added require as optional and recorded "absent/empty ⇒ no required obligations (vacuously covered)" — a true statement about the schema's shape that was read as a safety property, and was never one. Decision: the invariant wins. (a) Run path — decide's decidable, non-reserved arm returns a hard error when the covering binding's require is empty; the run exits 1 with a contributor-readable message naming the binding (class, environment), and zero forge writes happen (the guard sits before buildEvaluationInput, so no record is emitted either). It is deliberately NOT placed in selectBinding: that function is shared with assent compare, whose whole purpose is to compare permissive candidates, and refusing to compare an unsafe candidate would defeat it. (b) Lint — new hard error binding-require-empty, located to the binding, with a good/bad fixture pair registered in the E3-S08 hardErrorCorpus. (c) Reserved-class exemption, stated because it is a real carve-out: the reserved assent-policy meta-class is block-by-default (ADR-0015 §1) and its safety comes from GUARD 1 (any .assent/** edit BLOCKs), not from obligation coverage — and it cannot carry a non-empty require at all, because any prove rule in a reserved-bound pack routes the policy class to a vouch/APPROVE-arming disposition the reserved-class check forbids. The lint check therefore skips class == assent-policy; the run-path guard still refuses an empty require whenever the SUBJECT is not the reserved class. (d) Reconciliation — the schema description's "Absent or empty ⇒ no required obligations (vacuously covered)" and D-021's identical clause are superseded as safety statements by this row; D-021 is left byte-unchanged (supersede, don't edit); D-134's ARM-08 run-path consequence ("with no require: on the binding it turns a BLOCK into APPROVE+merge") is superseded the same way — the engine still decides APPROVE for an empty require, but decide (a) refuses before it, so that approve+merge path is gone. The schema's minItems: 1 is deferred to the next schema change window: schemas/**/*.json is under the ref-relative freeze guard (D-132) against the released v0.1.0 tag, which permits exactly one diff (the D-120 toolDigest description) and no other, so editing the frozen file in this lane would red hack/audit/exitgate_test.sh. Until that window, (a)+(b) are the enforcement. Not in scope, deliberately: the unmatched-edit fail-open (a rule that names an obligation but whose match selects nothing still marks it covered) shares the cover() invariant but is its own register row and is gated on a held captain decision; assent test does not arm APPROVE and is unchanged; assent compare is unchanged per (a). Scope note: examples/lint-fixtures/{fail-open,reserved-class}/ were touched — fail-open's bindings gained require: [reviewed] (the rule already proved it), and reserved-class/good is covered by the exemption; both were previously "clean" only because the check did not exist. Revert: delete checkBindingRequireEmpty and its corpus row, drop the decide guard, restore the two fail-open bindings, and the vacuous APPROVE returns.
D-185 2026-09-26 RVW-S02 — an unmatched EDIT never arms APPROVE: an additive fail-safe escalation in cover() mirrors the D-063/D-064 unmatched-whole-file-DELETE guard, and the promotion-gates comparison baseline is corrected because one of its cases had accidentally depended on the fail-open. Finding (report C-1, reproduced): cover() marks a required obligation covered as soon as any enforce-phase rule names it in prove.obligation — BEFORE it computes matchChanges (internal/core/aggregate/coverage.go:150-152 vs :154-160). So a rule that names the obligation but whose match selects none of the governed changes still marks it covered, contributes no finding, and the decision falls through to APPROVE with an EMPTY finding set: a policy whose only rule matches valueChanges.pointers: ["/partitions"] and proves non-destructive, under require: [non-destructive], APPROVED a change at /replicas with zero findings. GUIDELINES §Safety-1 and D-142's REQ-DEM-S10-02 both intend REVIEW. The intended runtime backstop, classify.ValidateRouting, has no production caller and assent run never runs lint, so nothing else catches it. Decision (captain: option (a), "add an unmatched-edit escalation to REVIEW, mirroring the D-064 unmatched-delete guard"): after the rule loop, for each VALUE-LEVEL change (ch.Path != ""), if no enforce-effective prove rule selects it, worse(decision, REVIEW) and emit a synthetic aggregate.unmatchedEdit finding (effect: require-review, code: change.unmatchedEdit, subject the change's subject). Scope — value-level edits only: whole-file (path=="") lifecycle events are the fileEvents domain — a delete is the D-064 guard's subject, an add is non-destructive (D-063) — so neither escalates here; this also leaves the D-016 golden's whole-file rename event untouched. Gate: the escalation runs only when requiredObligationCovered (some obligation the binding requires has an enforce proving rule, i.e. covered[obl]). An empty/absent require is D-184's run-path seam and the engine's vacuous-APPROVE reading is deliberately unchanged; a require with no proving rule already trips the uncovered guard. Without the gate a policy with no obligation layer would escalate every value change — outside C-1's scope. "Governed" (editGoverned) mirrors fileDeleteGoverned exactly: some enforce-effective prove rule selects the change, obligation-agnostic (a non-required signal rule that matches is a real evaluation, and the points model depends on it being able to leave the decision at APPROVE). Observe-phase (and pack-ceiling-observe) rules do NOT govern — their findings are decision-excluded, so treating them as governing would suppress escalation into APPROVE, the same D-063 fail-open the delete guard names. One finding per unvouched SUBJECT (deduped; a subject can carry many value changes and duplicate identical findings would be noise). Additive and deterministic: it only ever raises toward REVIEW via worse(), never relaxes a BLOCK, and the findings are canonically sorted; a fully governed changeset is byte-identical. Corpus correction, stated because it is a real behaviour change to a shipped example: examples/comparison/promotion-gates/cases/challenge-intervention-added changed /retentionMs under a baseline (prod-strict@6) with NO rule matching that pointer, so it APPROVED only because of this fail-open; with the guard the baseline REVIEWs and the case's stricter-intervention-added delta disappears. The baseline gains a permissive retention-permissive rule (proves a required obligation cleanly true), so the baseline genuinely governs and permits the edit and the candidate's retention-ack challenge is a real delta again — the record is byte-identical. Reproduced then fixed: the pre-fix Cover returned APPROVE findings=[]; the regression test TestUnmatchedEditFailsSafeReview pins the REVIEW + aggregate.unmatchedEdit finding, both polarities, the gate, the scope and the observe cases. Supersession: this row closes the clause D-184's Not in scope left open ("the unmatched-edit fail-open … is its own register row and is gated on a held captain decision"); D-184 is left byte-unchanged (supersede, don't edit). Spec: openspec/specs/p5-rvw-review-remediation/spec.md RVW-S02. Revert: delete the escalation block + editGoverned/requiredObligationCovered + ruleUnmatchedEdit, restore baseline.yaml, and the unmatched edit silently APPROVEs again.
D-186 2026-10-06 E10-S04 — the neutral capability model lands and RETIRES the ci_config_path '@' heuristic: GitLab arming stops in v1 until a decidable protected-pipeline-source predicate exists (E10 judgment call (e); OQ-33; ADR-0021 §3). The model (ADR-0021 item 3): forge.Capability is the closed eleven-flag enum seeded from the GitHub dossier §4, and adapters return a forge.CapabilityReport of `supported
D-187 2026-10-06 E10-S02/S03 — the composite forge.RunPort lands: cmd/assent references no adapter and the governed subject is MR-relative (ADR-0021 items 1/5/6/7; change p5-e10-github-forge-execution). internal/forge/runport.go declares the named composite port (Forge + Snapshotter + Resolver + GetMR + FileAtRef + FileAtBase/FileAtHead + Identity) with forge.ErrUnauthorized lifted to the port (S00 Q4's sentinel) and the authenticated identity exposed (item 7 — a PAT's identity is a User, an App's is a bot; the marker filter matches THAT identity, never "any bot"). The governed subject's content is addressed MR-relative — FileAtBase(project, mr, path) / FileAtHead(project, mr, path) — with the composite handle being the resolution of S00 §Forward obligations 2b (no constructor project binding; the port stays stateless). The GitLab adapter reads both sides at the SHAs of its own pinned MR read (REV1-S01 semantics preserved: judged bytes and record pins share one read chain), and a fork's head is read inside the SOURCE repository at the pinned source SHA — the fabricated whole-file DELETE on fork PRs is impossible by construction (S00 Q1), proven by the fork-head-unchanged-file-no-lifecycle conformance case plus its fork-head-genuine-delete-detected positive control. cmd/assent's two private port declarations are retired; depguard's cmd-adapter-boundary rule denies both adapter packages from cmd/assent with no symbol allowlist, and the neutral internal/forge/factory is the only adapter importer. gitlab.SyntheticDigest call-sites collapse onto Snapshot.Heads.MergeResultDigest (E10-S03) with the golden corpus byte-identical. Five S00-minted conformance cases (fork-head-*, forbidden-never-renders-as-absent, absent-file-still-renders-as-absent, own-markers-recognised-identity) run on both the fake and GitLab factories with catalog rows; the adapter-symbol allowlist gate is replaced by a zero-symbol scanner plus an interface-invariant guard in cmd/assent, both proven at both polarities. Revert: restore the anonymous forgePort/refFilePort literals and the FileAtRef(project, path, branch) governed reads — re-opening the fork-PR fabricated-DELETE and the move-and-restore SHA hazard.
D-188 2026-10-06 E10-S11 — the arming deltas (arming-revoked-on-push, review-dismissal-restrictions, merge-result-pinning) are consulted ONLY by the deferred-arming verb (EnablePullRequestAutoMerge, merge.go's armingConsultSet), which v1 production never calls (it is not on forge.RunPort; grep proves no caller outside tests). The immediate merge path — the only path assent run drives — is gated by forge.PreconditionFromReport's three forge-neutral consults (protected-pipeline-source, threads-block-merge, eligible-approval-evidence) alone. That hold is safe today ONLY because the same capability report grades protected-pipeline-source and eligible-approval-evidence UNKNOWN (the arming-refusing shape: OQ-33/OQ-34; D-186), so armEligible is false for every v1 GitHub run and the deltas being un-consulted cannot change any write decision. The obligation, stated so the deltas cannot silently become live: when either standing unknown is promoted to a decided probe (OQ-33/OQ-34), the promotion story MUST either (a) wire the adapter's arming-delta consult into the run path's arming decision at the buildDesired D-034 seam, or (b) re-derive the armed-path gate and record it here — a promotion that arms the merge path without consulting the deltas reintroduces the merge the deltas exist to refuse (an unproven revocation signal on GitHub means a write-access push does not auto-disarm auto-merge, dossier §3 delta 2). Recorded on the E10 branch-review round 2 finding 4. Revert: promote a consult without wiring it, and the deltas' fail-closed table (internal/forge/github/failclosed_test.go) becomes a table guarding dead code while the live path merges unconsulted.
D-189 2026-10-06 E10-S13 — the GitLab-by-default compatibility shape is recorded as the deliberate S13-01 amendment, and the final-review residual is triaged. REQ-E10-S13-01 says "ambiguity or an unrecognised host fails closed (no default-to-GitLab)". The shipped shape keeps the FLAG DEFAULT endpoint (https://gitlab.com, the -gitlab-endpoint flag's own default) resolving to the GitLab adapter when no --forge is named — so an existing GitLab CI run without --forge keeps working (the CHANGELOG compatibility note records it). The final Claude review's residual: (a) the bare empty-kind+empty-endpoint error branch is unreachable from assent run because the flag default is never empty — the "default" the REQ forbids is the ADAPTER default, which the shipped shape refuses (unknown hosts still fail closed; only the named gitlab.com flag default resolves to GitLab, and GITLAB_TOKEN goes only to gitlab.com); (b) --forge github --gitlab-endpoint http://api.github.com (plain http) is accepted — the endpoint override scheme is not enforced https, so a downgrade-redirect surface exists for the override case only; the default resolves https. Both residuals are deliberate compatibility choices, recorded here; tightening them is one line each if an adopter asks for stricter.