diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a52740..3a8a0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,9 +94,13 @@ repository still gets a decision, never by following the link; no release carrie - :memo: docs(openspec): fix two P0 design defects found by adversarial review - :memo: docs(openspec): correct the E10 story count to 19 after adding S00 - :memo: docs(openspec): fix 3 P1 design defects found by independent review of PR #45 +- :memo: docs(openspec): design P5-DEM public demo repositories (D-142, OQ-32) +- :memo: docs(openspec): add the missing P5-DEM backlog rows and correct the story count - :memo: docs(openspec): close 5 P2/P3 findings from the PR #45 re-review - :memo: docs(openspec): close H1/H2 — the FileAtRef enumeration was not exhaustive - :memo: docs(openspec): make the cmd/assent port boundary an enforced invariant (I1-I3) +- :memo: docs(openspec): fix 4 P1 design defects found by independent review of PR #47 +- :memo: docs(openspec): close F7/F8/F10/F11/F17/F18 from the PR #47 re-review ### Testing - :white_check_mark: test(release): anchor the D-120 note check on its header sentence, not the bare token diff --git a/docs/decisions/decisions.md b/docs/decisions/decisions.md index 951891e..2bb41c9 100644 --- a/docs/decisions/decisions.md +++ b/docs/decisions/decisions.md @@ -64,7 +64,7 @@ project/process decisions. | 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 `HeadComment`s, 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//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`. | +| 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//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:`) — 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). | @@ -146,3 +146,4 @@ project/process decisions. | 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). **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. | +| 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//` 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/.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 `continue`s 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:` (`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. | diff --git a/docs/planning/open-questions.md b/docs/planning/open-questions.md index 4c74740..3e5907b 100644 --- a/docs/planning/open-questions.md +++ b/docs/planning/open-questions.md @@ -34,3 +34,4 @@ | OQ-29 | **`PolicyProfile.spec.writes: false` is a frozen-schema field with NO runtime enforcement, and lint compels adopters to author it. Operator ruling needed.** [`docs/architecture/policy-profiles.md`](../architecture/policy-profiles.md) states the recorder-only guarantee as an **"architectural invariant, not a runtime best-effort check"** — line 13: a `writes: false` profile **"Never calls `Reconcile` — no approve, merge, block, thread sync, or other forge write"**. No code enforces it, because nothing on the write path reads it. Verified by grep over non-test sources: `aggregate.ResolveProfile` and `Result.WriteAllowed` have consumers only in `internal/lint/posture.go` and inside `internal/core/aggregate` itself; `aggregate.CoverWithProfile` is called only from `internal/compare`; `policy.LoadProfile` is called only from `cmd/assent/compare.go`; and **`cmd/assent/run.go` contains the string `Profile` zero times** — it evaluates via `aggregate.CoverWithPhaseCeiling` (`run.go:533`) and reaches `buildDesired`/`forge.Reconcile` without ever loading or consulting a profile. `internal/core/aggregate/profile.go:97` documents the missing link in its own words: *"A downstream forge step reads `WriteAllowed` to know whether this run may arm/merge or is recorder-only"* — **there is no such downstream forge step.** So a `writes: false` profile does not make `assent run` recorder-only; the run behaves exactly as if no profile existed. **Why it is not merely internal:** `writes` is a REQUIRED field of the frozen `schemas/policy/v1alpha1/profile.schema.json`, whose description reads *"true = this profile authorizes forge writes for bindings in its scope; false = recorder-only"*, and the `single-writer-profile` lint hard error (`internal/lint/posture.go:83`) fails a tree where zero or more than one `writes: true` profile covers a binding — so adopters are **compelled** to author a field whose `false` value does not do what the schema says. **RAISED TO P1 on 2026-08-09 — the stated escalation condition was ALREADY TRUE when it was written (audit DOC-04).** The original text read: *"Severity today is P2 only because `docs/architecture/policy-profiles.md` is NOT in the mkdocs nav, so the invariant claim is not on the docs site. If that directory ever enters the nav it becomes P1."* That rests on a false premise — **MkDocs publishes every file in `docs_dir` regardless of `nav`**; the nav controls navigation, not publication. Measured live on 2026-08-09, not reasoned: `curl -sI https://platformrelay.github.io/Assent/architecture/policy-profiles/` returns **200**; `sitemap.xml` carries **63 `` entries** against ~10 nav entries; `docs/planning/**` is fully published too; and the page's own words — the recorder-only guarantee stated as an *"architectural invariant, not a runtime best-effort check"* — are in the site's `search/search_index.json`, which indexes 420 sections and returns `architecture/policy-profiles/#write-vs-recorder-only` for that phrase. **So the published false safety guarantee is not hypothetical; it has been live the whole time, and it is searchable.** This is the D-134 shape exactly, and it is P1 by this question's own criterion. **`GUIDELINES.md`'s "docs published on the future site = product docs under `docs/` only; `docs/planning/`, `openspec/`, and agent-context stay out of the mkdocs nav" is read as a publication boundary; it creates only a NAV boundary, and nothing enforces the intended one** — a second, separate gap worth closing (an `exclude_docs`/`not_in_nav` setting, or moving non-product pages out of `docs_dir`). **Ruling needed, deliberately not taken here:** (a) implement the gate — load the covering profile on the `run` path and refuse `Reconcile` when `WriteAllowed` is false, making the documented invariant real; (b) retract the invariant language, restate `spec.writes` as comparison-scope metadata only, and say so in the schema description; or (c) accept the gap explicitly and annotate the doc, as ADR-0009 was annotated. **Not to be resolved by silently changing the frozen schema or the lint rule** — `writes` is a frozen contract field and the lint rule is load-bearing for the compare path. | **P1 — both stated conditions are already met**: the page is published (200) and indexed, and v0.1.0 already shipped the recorder-only guarantee. Needs a ruling before v0.2.1 | Found during the D-134/D-135 docs-truth lane (review finding SURF-08). Cross-referenced from [D-135](../decisions/decisions.md). Evidence: `internal/core/aggregate/profile.go:95-101`, `internal/lint/posture.go:200-215`, `cmd/assent/run.go:533`, `schemas/policy/v1alpha1/profile.schema.json:25-28` | | OQ-30 | **Is a `pull_request`-scoped CHANGELOG drift gate viable now that D-136 skips merge commits? The guard is retained with NO demonstrated reason — its original one is dead and its proposed successor measures false.** D-125 skipped the gate on `pull_request` because `refs/pull/N/merge`'s synthetic merge subject rendered into the generated changelog, so no committed `CHANGELOG.md` could match. **D-136 killed that reason** — that commit is a merge commit and is now skipped. The successor reason drafted in D-136's first version — "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, red by construction" — was then measured four ways and **could not be made true**: (1) PR #41's **live** `refs/pull/41/merge` (`491bb2a`, head `49eebb3` into base `7513d79`) rendered with the new `cliff.toml` → `verify-changelog: ok`, **0 diff lines**; (2) the direct counterexample — the same head merged into a `main` that **had** moved (`1d8aa60`, containing PR #40) → `verify-changelog: ok`, **0 diff lines**, i.e. not red with the base moved; (3) a synthetic sandbox where base and lane each add a commit to the **same** cliff group and each regenerate → `CONFLICT (content): Merge conflict in CHANGELOG.md`, so the PR is unmergeable, GitHub mints no merge ref, and the gate never runs. (4) **The strongest one, taken last and re-run rather than transcribed:** GitHub RE-MINTED `refs/pull/41/merge` against the moved base after all of the above. Re-fetched live — `7715bf7`, head `ee5e527` into base `1d8aa60` — and put through the real gate script: **`verify-changelog: ok`, 0 diff lines, 0 merge subjects rendered.** That is not a simulation: it is the exact artifact a `pull_request`-scoped gate would evaluate, with the base moved past the fork point AND after the lane had merged `main` in — the direction the finding below shows is hazardous — and it is green. Measurement (1)'s `491bb2a` at base `7513d79` is its stale predecessor, kept only to show the result did not depend on the base standing still. **Mechanism the dead premise overlooked:** the merge ref's `CHANGELOG.md` is not "the branch's committed file" — it is the three-way MERGE RESULT, which already contains the base's lines, because the file is merged like any other. So base movement ends in clean-and-matching or conflict-and-no-merge-ref. **The third outcome EXISTS, and merge DIRECTION decides it — measured while writing this row.** A clean textual auto-merge whose line order differs from git-cliff's topological order is red with no author error, and it reproduced immediately: merging `origin/main` **into the lane** (lane as first parent) auto-merged `CHANGELOG.md` without conflict and then failed `verify-changelog` on pure ordering — one `docs(compare)` line moved and PR #40's lines landed in a different position. The SAME two commits merged in the **merge-ref direction** (base `1d8aa60` as first parent, measurement (2) above) matched exactly. git-cliff's traversal follows parent order, so first-parent choice changes the render. This does not revive the retired premise — GitHub always mints the merge ref base-first, which is the direction that matched — but it means the clean-and-matching outcome is a property of that direction, measured on two merges, not a proof. It also re-confirms D-125's surviving rule: regenerate after any `git merge origin/main`. **Still untested:** behaviour on `pull_request_target`, on a PR from a fork, and after a force-push that re-mints the merge ref. **Counter-evidence for enabling it:** the only red reproduced on any merge ref was a branch that had not run `task changelog-write` for its own commits — a **true positive the gate exists to catch**, which argues the PR placement may now be correct rather than merely harmless. **Correction, folded in from the PR #41 review because it belongs in the row and not only in a review thread:** that review first read these greens as "the evidence points toward the PR gate being viable", and then took it back as **one measurement short**. The direction finding above supplies a false-positive mechanism it had not considered — a clean textual auto-merge whose line order differs from git-cliff's topological order reds with no author error and no author fix available. Four green measurements are therefore NOT a green light; on today's evidence the gate would not be enabled. **Ruling needed (deliberately not taken here, operator's call):** (a) enable the step on `pull_request` and delete the guard; (b) keep the guard and record the real reason once someone finds one; or (c) keep the guard permanently on cost/noise grounds and say so, rather than on a mechanism. Not to be resolved by deleting the guard on the strength of these three measurements alone — they show the claimed failure did not reproduce, not that no failure exists. | Before any change to the `pull_request` guard on the changelog step in `.github/workflows/verify.yaml`; not a release blocker — the guard is fail-safe (the gate runs locally in `task check` and on push-to-main) | Raised by the PR #41 review (finding CL-02) against D-136's first draft; measurements reproduced independently before recording. Sites now pointing here: `Taskfile.yml` `check:`, `.github/workflows/verify.yaml`, `hack/release/README.md`, `hack/release/changelog_gate_test.sh` §3. See [D-125](../decisions/decisions.md) and [D-136](../decisions/decisions.md) | | OQ-31 | **May the GUARD-1 self-edit BLOCK path write a summary or supersession note, or is "zero forge writes on a self-modifying MR" absolute? If it is absolute, what channel carries the BLOCK to the human reviewer — given that no thread is posted and the exit code is `0`?** Raised by RELI-01 ([D-138](../decisions/decisions.md)) and deliberately left **UNDECIDED**. The tension is real in both directions. **For absolute:** `openspec/specs/p5-aud-audit-remediation/spec.md` pins *"the decision is BLOCK with zero forge writes (GUARD-1 dominance over the gap-degrade)"* as a frozen acceptance criterion, and the guard exists so that an MR editing `.assent/**` cannot make assent vouch for its own policy — any write is a write the MR's own content influenced. **Against absolute:** the only human-visible surface then keeps whatever the previous run said, which today can be `✅ Decision: APPROVE`, so the guard's output is *invisible* to the reviewer it protects, and D-130's compensating control (a REVIEW rerun upserts the summary and adds an unresolved discussion) does not reach this path because no thread is posted. Zero *authority* writes need not mean zero *communication*. **Options, none taken here:** (a) keep it absolute and carry BLOCK on a non-forge channel — a non-zero exit code, or a required CI job status; (b) permit exactly one write, a fixed-text supersession/BLOCK note with no policy-derived content, which cannot be steered by the MR; (c) permit the summary upsert but not the thread. (b) and (c) both **reopen the frozen criterion above and need an openspec change proposal first** — spec before code. Note that (a) changes an exit-code contract wrapper scripts rely on ([`docs/usage/cli.md`](../usage/cli.md)), so it is not the free option it looks like. | Before the RELI-01 fix lands (v0.2.1) | Found by the 2026-08-09 audit's reliability lens; recorded in [D-138](../decisions/decisions.md). Evidence: `cmd/assent/run.go` step-9 GUARD switch, `openspec/specs/p5-aud-audit-remediation/spec.md`, `openspec/specs/p5-e5-provider-host/spec.md` REQ-E5-S08-03 | +| OQ-32 | **No provider transport can carry a credential, so NO provider can call Entra ID, Keycloak, or any token-authenticated IdP directly — and nothing says so.** Found while designing P5-DEM (D-142). Verified across three surfaces that agree: `CallHTTP` (`internal/provider/transport.go`) sets **only** `Content-Type: application/json` — no header map, no bearer token, no client certificate; the repo-side provider schema (`schemas/policy/v1alpha1/config.schema.json` `$defs/provider`) is `additionalProperties:false` over exactly `{type, url, failure}`, so there is nowhere to put one; and `ScrubEnv`/`ScrubArgv` build the exec child's environment **from scratch** and refuse any name matching `(?i)(TOKEN\|SECRET)` **even when explicitly configured**, so the exec tier cannot carry one either. **This is not a bug — it is ADR-0015 §7 working exactly as designed**, and Spike C's `TestIsolation` proves it against a deliberately hostile provider that exfiltrates its whole environment and stdin. **What has never been written down is the consequence:** Entra ID and Keycloak both require a bearer token on every call, so the only shape that works today is a **broker** — a service holding the IdP credential *itself*, reachable by assent without one (loopback/sidecar, or mTLS terminated outside assent's transport). That is arguably the *correct* architecture: the credential never enters the decision path and a compromised provider's blast radius stays bounded. But it is undocumented, and it **narrows what `docs/vision.md:67` promises** ("pluggable providers: Keycloak, LDAP, GitLab/GitHub groups, ownership files, custom plugins") and what ADR-0004 §1 planned ("OIDC/Keycloak group lookup, LDAP" as builtins — never shipped). `docs/architecture/c4-context.md:19` is currently the **only** place stating the truth: *"Keycloak / LDAP: no builtin — reachable only via the generic HTTP/exec provider transport."* **Ruling needed (deliberately not taken):** **(a)** bless the broker pattern, document it in the provider-author guide, and amend `docs/vision.md:67` + ADR-0004 §1 to stop implying direct IdP calls — **RECOMMENDED**: costs nothing, keeps ADR-0015 §7 and the isolation proof intact, and is what DEM-S02/DEM-S03 are already written against; **(b)** add a narrow repo-side credential channel (header or secret-ref) to the HTTP transport — **reopens a frozen schema AND the trust boundary the hostile-provider isolation proof rests on**, and would need its own ADR; **(c)** state the limitation and add nothing. Note this is **not** merely a docs question under (a): a reader of the vision page today would reasonably budget a Keycloak integration as "configure a builtin" and discover mid-implementation that they must also deploy and operate a broker. **Not to be resolved by quietly adding a header field** — that is option (b) and it is a trust-boundary change. | Before DEM-S02 publishes the provider-author guide (the guide must state one of these answers); **not** a release blocker — the current behaviour is fail-safe, just undocumented | Found designing [P5-DEM](https://github.com/PlatformRelay/assent/blob/main/openspec/specs/p5-dem-demo-repos/spec.md) (judgment call (e)); recorded in [D-142](../decisions/decisions.md). Evidence: `internal/provider/transport.go` `CallHTTP`/`ScrubEnv`/`ScrubArgv`, `schemas/policy/v1alpha1/config.schema.json` `$defs/provider`, `docs/planning/spikes/spike-c-provider.md` § Isolation evidence, ADR-0015 §7, ADR-0004 §1, `docs/vision.md:67`, `docs/architecture/c4-context.md:19` | diff --git a/openspec/specs/backlog.md b/openspec/specs/backlog.md index bb91a59..ff50a13 100644 --- a/openspec/specs/backlog.md +++ b/openspec/specs/backlog.md @@ -605,6 +605,42 @@ LGTM** (published contract + the decision path itself). Independent of E10; may | E11-S12 | Docs & maturity truth; retire ADR-0002's "pluggable half unbuilt" line | **[autonomous]** | S11 | nothing still calls Rego locked | | E11-S13 | Exit gate | **[autonomous]** | S00–S12 | **the E11 exit gate** | +### P5-DEM — Public demo repositories + provider extensibility proof — spec: [p5-dem-demo-repos](p5-dem-demo-repos/spec.md) + +Designed spec-first by **D-142**; the provider-credential gap found while designing is **OQ-32**. +Two public demo repos on a **two-tier contract**: tier 1 (`git clone && assent test .`) is +forge-independent and deliverable now on both; tier 2 (live MR/PR) is GitLab now, GitHub blocked +on E10-S18. Split by **governance shape, not by tool** — repo 1 is *referential* (an ACL names a +topic and a principal), repo 2 is *blast radius* + the opaque-change fallback. **15 stories, +S00–S14.** 🔴 **S00 is not optional and not cosmetic**: `(class, environment)` binding routing is +**not wired** (`run.go:493` fails closed on >1 binding; `test.go:366` collapses to strictest but +fails closed when bindings differ in `class`/`packs`/`require[]`), so both demo repos fail closed +in **both** tiers on **both** forges as designed. S13 is **operator-gated** — creating public +repos under the `PlatformRelay` org is outward-facing and **not** covered by AGENTS.md rule 2's +push grant to `PlatformRelay/assent` (D-142 judgment call (d)). + +🔴 **No DEM story is implementable until its REQs carry `Test:`/`Verify:`/`Level:` annotations** — +the epic has **0** today against E10's 53, E11's 36 and E6's 42, so "green" is undefined for all +15 stories. Deferred to its own change, not waived. + +| ID | Story | Execution | Depends on | Gate contribution | +| --- | --- | --- | --- | --- | +| DEM-S00 | 🔴 Wire `(class, environment)` binding routing at the `cmd/assent` seam; **delete** `selectBindingForTest`'s strictest-collapse | **[autonomous · engine-grade · LGTM]** | none | **do first** — without it both repos fail closed in both tiers; supersedes D-060 | +| DEM-S01 | Provider host declarations: worked examples, docs, and a both-polarity gate | **[autonomous]** | S00 | closes G1 — today **every** shipped example's provider resolves to nothing | +| DEM-S02 | Provider-author guide | **[autonomous — but see OQ-32]** | S01 | 🔴 the guide **cannot publish** until OQ-32 is ruled; S02 may write everything else and must cross-link the OQ, but the `docs/vision.md:67` + ADR-0004 §1 amendment is operator-gated | +| DEM-S03 | Reference IdP-groups broker provider (`contrib/`, Entra + Keycloak adapters) | **[autonomous]** | S02 | the L2→L3 step is two files, zero lines of assent rebuilt | +| DEM-S04 | New sample shapes: Kafka ACL + ArgoCD Application | **[autonomous]** | S03 | referential shapes no shipped example exercises | +| DEM-S05 | `kafka-acl` class + cross-manifest reference rules | **[autonomous]** | S04 | doubles as the **E11-S01 tier-1 ceiling probe** (D-141) | +| DEM-S06 | `argocd-application` class + rules | **[autonomous]** | S05 | `builtin/resource-owner` (REF-GAP-1) end to end | +| DEM-S07 | Repo 1 assembly (`assent-demo-platform`, GitLab) | **[autonomous]** | S06 | tier 1 green on repo 1 | +| DEM-S08 | Raw `.tf` shape + HCL structuring truth | **[autonomous]** | S00 | states what assent can and cannot see in HCL | +| DEM-S09 | `tf-module-instance` class + rules | **[autonomous]** | S08 | magnitude / blast-radius archetype | +| DEM-S10 | `tf-backend` **ungoverned → REVIEW** (D-063) | **[autonomous]** | S09 | must **determine** unmatched-edit behaviour by running it, not assert it | +| DEM-S11 | Repo 2 assembly (`assent-demo-terraform`, GitHub) | **[autonomous]** | S10 | also **E10-S18's live adoption target** | +| DEM-S12 | Demo CI + mirror-drift gate | **[autonomous]** | S07, S11 | in-tree under `examples/demo/**` **so that `task check` will cover it — it does NOT today**: `task check` runs no example dogfood, `task dogfood-examples` is called by nothing, and `verify.yaml` duplicates it as a hardcoded pack loop. S12 must edit **both** | +| DEM-S13 | 🔴 Publish the repositories under the `PlatformRelay` org | **[operator]** | S12 | **operator-gated** — outward-facing, beyond rule 2's grant | +| DEM-S14 | Live tier-2 proof on GitLab | **[infra-gated · operator]** | S13 | **the DEM exit gate** | + ## Phases 3–5 Epic paragraphs (goal, ADR constraints, exit gate, story seeds) in diff --git a/openspec/specs/p5-dem-demo-repos/spec.md b/openspec/specs/p5-dem-demo-repos/spec.md new file mode 100644 index 0000000..20d1a99 --- /dev/null +++ b/openspec/specs/p5-dem-demo-repos/spec.md @@ -0,0 +1,758 @@ +# P5-DEM — Public demo repositories + provider extensibility proof + +**Epic ID / REQ prefix:** `DEM` / `REQ-DEM-S0n-nn`. + +**Origin:** operator instruction (2026-08-10) — *"does there already exist a sample gitlab +repository we can test with as well as a sample github repository? … design two repositories +showing different self service patterns … Everybody should be able to see assent in action +themselves … I want to check how extensible our user resolution is."* Decisions recorded as +**D-142**; the provider-credential gap found while designing is **OQ-32**. + +**Answer to the question that opened the epic: no.** Neither a public sample GitLab repo nor +any GitHub presence exists. What exists today, and must not be confused with it: + +| Thing | What it actually is | Public demo? | +| --- | --- | --- | +| `examples/repos/{topic-registry,service-catalog,infra-vars}` | in-tree **layouts** — governed content only, no `.assent/` tree | no | +| `examples/packs/{topic-registry,service-catalog,infra-vars}` | in-tree **policy trees** — `assent lint`/`assent test` green under `task check` | no | +| `examples/repos/corpus/{julieops,octodns,kubernetes-org}` | vendored open-source excerpts (OQ-16) | no | +| `gitlab.com/konrad.heimel/assent-lab` | the operator's personal project used for the **D-042** adoption proof | no — private-shaped, not a showcase | +| GitHub | nothing. The adapter itself is spec-only (E10, D-140) | no | + +So "everybody can see assent in action themselves" is currently false, and the two epics +unlocked on 2026-08-10 do not make it true: E10 ships a GitHub *adapter*, not a repo to point +it at. + +--- + +## The constraint that shapes the whole epic — and a second one found while designing it + +### 🔴 `(class, environment)` routing is not wired, so a multi-class demo repo fails closed in **both** tiers, on **both** forges + +Found by reading the code against this design, not assumed. It is the single most consequential +finding of the design session, and it invalidates the assumption every demo repo rests on — +that a repo can showcase several self-service patterns at once. + +- **`assent run` (tier 2):** `cmd/assent/run.go:493` `selectBinding` **fails closed on any + RulesetBinding with more than one binding.** Its own comment: *"Full (class, environment) + routing needs the Config class-matcher … which is not wired in this lane."* +- **`assent test` (tier 1):** `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[]`**, because + collapsing those could flip a decision. + +Both demo repos as designed have **three classes with different `require[]` lists**. So as +specified they fail closed on `assent run` *and* on `assent test`. **This is not a GitHub gap +and not a forge gap** — it is a routing gap at the `cmd/assent` seam that no existing example +surfaces. + +**Why nobody noticed:** D-060 recorded the shipped packs' dev/prod split as *"empirically +decision-neutral for the corpus (every case identical under dev and prod)"*. That is the tell. +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 that +needs real routing — which is exactly what a demo is for. + +**Consequence for scope:** **DEM-S00 wires the matcher**, ahead of everything, blocking every +assembly story. It is not a workaround; it is the feature the demo forces. Note also that the +single clearest demonstration in the epic — **the identical diff that APPROVEs in `dev` and +REVIEWs in `prod`** (DEM-S09-03) — is **currently impossible in either tier**, for the same +reason. + +### E10 is spec-only, so GitHub tier 2 does not exist yet + +**E10 is spec-only.** `openspec/specs/p5-e10-github-forge/spec.md` S06–S12 are the adapter and +S18 is `[infra-gated · operator]` live proof. **assent cannot run on a GitHub PR today.** A +design that ships a GitHub demo repo implying otherwise would be a lie in the exact class +GUIDELINES calls out ("examples that don't run are lies"). + +The epic therefore commits to a **two-tier demo contract**, stated on the front page of both +repos rather than buried: + +- **Tier 1 — clone and run. Forge-independent. Deliverable once DEM-S00 lands** (today only for + a single-class repo — see the routing finding above). + `git clone && assent test . && assent lint .` — real rules, real ChangeSets, real decisions, + with **no forge account, no token, no network, no IdP**. Facts come from committed + `facts.yaml` fixtures; the E6 adopter-test harness already delivers everything this needs. + This tier is the "everybody" claim and it is the epic's primary deliverable. +- **Tier 2 — live MR/PR on a real forge.** GitLab: **available now** (E4 adapter, D-042 + precedent). GitHub: **blocked on E10-S18** — the repo ships tier-1-only and says so. + +Tier 1 is not a consolation prize. It is the only tier a reader can exercise in sixty seconds +without credentials, and it is the tier that survives a forge outage, an expired token, or a +reader who does not want to hand a demo an OAuth scope. + +--- + +## The two repositories + +Four self-service patterns were named (raw `.tf`, tfvars, Kafka topic/ACL, ArgoCD +Application). They are split by **governance shape, not by tool** — because the shape is what +assent has opinions about, and grouping by tool would produce two repos that demonstrate the +same three archetypes twice. + +### Repo 1 — `assent-demo-platform` (GitLab) + +**Self-service pattern: *request a platform resource.*** A team opens an MR asking for a Kafka +topic, a Kafka ACL, or an ArgoCD Application. + +The distinguishing shape is **referential**: a request names things that live in *other files*. +An ACL names a topic and a principal; an ArgoCD Application names a project, a destination +namespace, and a source repo. **The decision cannot be made by reading the changed file +alone** — which is precisely the class `builtin/resource-owner` (E5-S08, "closes REF-GAP-1") +and `builtin/repo-file` (E5-S07) exist for, and which no shipped example currently exercises +end to end. + +| Class | Path glob | Format | Status | +| --- | --- | --- | --- | +| `kafka-topic` | `platform/kafka//topics/*.yaml` | YAML, one file per topic | extends the existing `topic-registry` shape | +| `kafka-acl` | `platform/kafka//acls/*.yaml` | YAML, one file per ACL grant | **new shape** | +| `argocd-application` | `platform/argocd//applications/*.yaml` | YAML, Argo CD `Application` kind | **new shape** | + +Archetypes exercised: **ownership**, **allow-listed-fields**, **no-destruction**, +**environment-split**, **freshness** — plus **cross-manifest reference integrity**, which no +existing archetype covers. + +Forge: **GitLab**, because the adapter is mature and both tiers work on day one. + +### Repo 2 — `assent-demo-terraform` (GitHub) + +**Self-service pattern: *change infrastructure.*** A team opens a PR that either instantiates a +blessed module (`.tf`) or tunes per-environment inputs (`.tfvars`). + +The distinguishing shape is **magnitude and blast radius**: how much changed, in which +environment, and whether it destroys state-bearing resources. It is also the epic's honest +demonstration of the **opaque-change fallback** — HCL that the fact model cannot structure must +route to REVIEW rather than be silently judged. + +| Class | Path glob | Format | Status | +| --- | --- | --- | --- | +| `tf-module-instance` | `stacks///main.tf` | raw HCL | **new shape** | +| `tf-vars` | `stacks///*.tfvars` | tfvars | extends the existing `infra-vars` shape | +| `tf-backend` | `stacks//backend.tf` | raw HCL | **deliberately ungoverned → always REVIEW** (D-063) | + +Archetypes: **bounded-change**, **environment-split**, **no-destruction**, +**allow-listed-fields**, **schema-validity**, plus the **opaque-change fallback** and the +**ungoverned-whole-file → REVIEW** route. + +Forge: **GitHub**, for two reasons. Terraform-on-GitHub is the idiom readers expect; and this +repo becomes **E10-S18's live adoption target**, so the epic that needs a real GitHub +repository gets one instead of minting a throwaway. Until S18 lands, repo 2 is tier-1 only. + +### Why not one repo + +A single repo would collapse the two shapes into one `.assent/config.yaml` and hide the thing +worth showing: that **class + environment + binding** is how an adopter separates governance +regimes, and that two genuinely different regimes look different. Two repos also let the +GitHub one be honestly tier-1-only without degrading the GitLab one. + +--- + +## Provider extensibility — the user-resolution answer + +The second half of the operator's question: *how easily can an adopter swap in Entra ID, +Keycloak, or something else?* + +### What was verified (not assumed) + +The seam is **genuinely well built, and extending it requires no core change and no fork**: + +- The wire contract is **frozen and published** — + `schemas/provider/v1alpha1/request.schema.json` (`FactQuery`) and `response.schema.json` + (`FactResponse`), `apiVersion: provider.assent.dev/v1alpha1`, `additionalProperties:false` + throughout, with the fail-closed state machine (`resolved` / `unavailable` / `invalid` / + `expired`) and the `resolved ⇒ value+expiresAt` implication encoded in the schema itself. + `API_STABILITY.md:20` binds provider protocol majors to exact negotiation. +- The host classifier produces **exactly one fact per requested output on every path** + (`ResolveFacts`), so a provider that times out, returns garbage, or omits an output cannot + produce a silently-absent key. +- Projection minimization means a provider sees **only the JSON Pointers it declared, + intersected with what the change touched** — never full content without the explicit + `trusted-full-content` capability. +- Extension is a **two-file, repo-side change on the protected target ref**, verified in + `cmd/assent/provider_host.go:61,81-88`: + 1. `.assent/config.yaml` → `providers.: {type: http, url: …, failure: closed}` + 2. `.assent/providers/.json` → the host declaration carrying typed `outputs` + Both are read **from the target ref**, so an MR author cannot alter their own fact semantics. + **No rebuild, no plugin compile, no fork.** + +That is a better answer than "extensible in principle". But four gaps stand between it and +"easily extendable", and this epic states all four rather than demoing around them. + +### G1 — no worked example exists, and no document names the path *(P1 — blocks the demo)* + +`find examples -type d -name providers` returns **nothing**. All three shipped example packs +declare `providers:` blocks (`builtin/gitlab-groups`, and `type: http` pointing at +`quota.example.com` / `oncall.example.com` / `sizing.example.com`) with **no matching +`.assent/providers/.json`**. On a live run, `provider_host.go:83-87` hits +`FileAtRef` → not found → `continue`: **every one of those providers is silently skipped.** +Grep for `providers/` across `docs/usage/**` and `docs/*.md` returns nothing — the path an +adopter must know is documented nowhere. + +So today an adopter copying a shipped example gets a provider block that does nothing, with no +error and no documentation to recover from. **This is the single largest barrier to the +extensibility claim**, and it is cheap to fix. → **DEM-S01**. + +### G2 — the transports cannot carry a credential, so no provider can call an IdP directly *(P1 — architectural; OQ-32)* + +Verified across three surfaces that agree: + +- `CallHTTP` (`internal/provider/transport.go`) sets **only** `Content-Type: application/json`. + There is no header map, no bearer token, no client certificate. +- The repo-side provider schema (`config.schema.json` `$defs/provider`) is + `additionalProperties:false` over exactly `{type, url, failure}` — nowhere to put one. +- `ScrubEnv`/`ScrubArgv` build the exec child's environment **from scratch** and refuse any + name matching `(?i)(TOKEN|SECRET)` **even when explicitly configured** — so the exec tier + cannot carry one either. + +This is not an oversight; it is ADR-0015 §7 working exactly as designed, and Spike C's +`TestIsolation` proves it against a deliberately hostile provider. But it has a consequence +nobody has written down: **Entra ID and Keycloak both require a bearer token on every call, so +neither can be reached by a provider assent invokes.** + +The only shape that works today is a **broker**: a small service that holds the IdP credential +*itself* and is reachable by assent without one — loopback/sidecar, or mTLS terminated outside +assent's transport. That is a defensible and arguably correct architecture (the credential +never enters the decision path, and the blast radius of a compromised provider stays bounded), +but it is **undocumented and undecided**, and it narrows what `docs/vision.md:67` promises +("pluggable providers: Keycloak, LDAP, GitLab/GitHub groups…"). `docs/architecture/c4-context.md:19` +is already the only place telling the truth: *"Keycloak / LDAP: no builtin — reachable only via +the generic HTTP/exec provider transport."* + +**The demo must model the broker explicitly** rather than implying a direct IdP call, and the +operator ruling on whether to bless the broker pattern, add a narrow credential channel, or +state the limitation is **OQ-32**. DEM-S03 is written to be correct under the broker reading +(the recommended default) and is not blocked by the ruling. + +### G3 — exec providers must be re-pinned on every version bump *(P2 — ergonomics, state it)* + +`ExecDeclaration.Digest` is required before spawn (D-065 / REQ-E5-S03-02), so a third party +shipping an exec provider forces every adopter to recompute a `sha256:` pin on each release. +Real cost, correct trade-off, **not fixed here** — the docs say so, and the reference provider +is HTTP precisely to avoid it. + +### G4 — no builtin exists for OIDC/Keycloak/LDAP, and the epic recommends keeping it that way + +ADR-0004 §1 lists "OIDC/Keycloak group lookup, LDAP" among planned builtins; only +`forge-groups`, `repo-file`, and `resource-owner` shipped. **Recommendation: do not add them.** +Each is an unbounded authentication surface (token acquisition, refresh, tenant/realm +discovery, cert pinning) that would land inside the decision path's dependency tree for no +capability the HTTP transport lacks. The extensibility answer is a *published contract plus a +copyable reference implementation*, not a growing builtin catalogue — and it is a stronger +answer, because it works for the adopter whose IdP nobody has heard of. + +### The four-layer demo of user resolution + +Both repos demonstrate the ladder, each rung runnable: + +| Layer | Mechanism | Network? | Tier | +| --- | --- | --- | --- | +| **L0** | `.assent/tests/**/facts.yaml` fixtures | none | 1 — works for every reader | +| **L1** | `builtin/repo-file` + `builtin/resource-owner` — ownership from files in the repo | none | **2 only** — see the fence note below | +| **L2** | `builtin/forge-groups` — the author's forge group membership | forge only | 2 (GitLab now, GitHub with E10) | +| **L3** | `type: http` → **your** IdP broker | broker only | 2, adopter-supplied | + +🔴 **L1 does NOT run at tier 1, and the README must not imply it does.** `assent test` is +**fenced from the live provider host by a guard test**: `cmd/assent/test_provider_fence_test.go:69-80` +fails the build if `test.go` imports `internal/provider` or contains `ResolveFacts(`, +`ResolveFactsChecked(`, `CallHTTP(`, `CallExec(` or `resolveRunFacts(` — *"assent test must stay +on facts.yaml stubs (ADR-0014)"*. Tier-1 facts come **solely** from `adoptertest.MapFacts` +(`internal/adoptertest/adoptertest.go:109-119`). So at tier 1 a reader sees ownership resolve +from a **fixture literal**, not from repo files. This is deliberate existing design, not a +defect — but claiming L1 at tier 1 would make the demo's load-bearing claim false, which for a +demo whose whole premise is *"everybody can see assent in action themselves"* is the worst +possible place to over-claim. Any README sentence implying live provider resolution at tier 1 is +a story failure. + +L3 ships a runnable reference: `contrib/providers/idp-groups/` — one small Go binary, two +adapter shapes (**Entra ID** `transitiveMemberOf`, **Keycloak** `users//groups`), both +emitting the identical `FactResponse`. Promoted from `hack/spikes/provider/toy.go`, which +already proves the envelope and the fail-closed states. The demo README's L2→L3 step is +**two files changed, zero lines of assent rebuilt** — which is the operator's question, +answered by something the reader can run rather than by a claim. + +--- + +## Judgment calls (decide-and-log / operator) + +**(a) DECIDED — the demo trees live in-tree under `examples/demo//` and are mirrored to +the public repositories.** Authoring them only in the public repos would put them outside +`task check`, and an ungated example rots on the first schema or engine change — the exact +failure GUIDELINES names. In-tree means a broken demo reds assent's own build (DEM-S12). Cost: +a mirror step (DEM-S13) and the risk of mirror drift, which DEM-S12 gates by diffing. + +**(b) DECIDED — platform→GitLab, terraform→GitHub, and repo 2 is tier-1-only until E10-S18.** +Assignment is a judgment call, not a technical constraint: both trees are forge-independent at +tier 1. The reasoning is idiom (Terraform+GitHub) plus giving E10-S18 a real target. **Repo 2's +README states the pending live tier in its first screenful** — a GitHub repo that looks like it +runs assent on PRs and does not would be worse than no repo. + +**(c) DECIDED — no Keycloak/Entra/LDAP builtins; broker-shaped HTTP provider plus a reference +implementation.** Rationale in G4. Reversible: adding a builtin later is additive. + +**(d) 🔴 OPERATOR — creating public repositories under the `PlatformRelay` org is outward-facing +and is NOT covered by existing push authorization.** AGENTS.md rule 2 grants push to +`PlatformRelay/assent`; minting new public repos, choosing their names, and seeding demo +branches is a separate act. **DEM-S13 is operator-gated and nothing before it is blocked** — +S01–S12 land in this repo and the trees are complete and green before a public repo exists. + +**(e) 🔴 OPERATOR (OQ-32) — is the broker pattern the blessed answer for IdP-backed providers, +or should a narrow credential channel exist?** Options: **(e1)** bless the broker, document it, +amend `docs/vision.md:67` to stop implying direct Keycloak/LDAP calls (**RECOMMENDED** — costs +nothing, keeps ADR-0015 §7 intact); **(e2)** add a repo-side header/secret-ref channel to the +HTTP transport — reopens a frozen schema *and* the trust boundary that Spike C's isolation +proof rests on; **(e3)** say nothing and let adopters discover it. Only (e2) blocks anything, +and it blocks nothing in this epic. + +**(f) DECIDED — repo 1's cross-manifest reference rules double as an E11 tier-1 ceiling probe.** +D-141 requires E11-S01 to document, with concrete rules per shape, where CEL runs out — +"cross-manifest" is one of the four named shapes. DEM-S05 writes real cross-manifest rules and +**records whether each is CEL-expressible**, feeding E11-S01 evidence instead of hypotheticals. +DEM-S05 does **not** depend on E11 and must not wait for it: any rule found to exceed tier 1 is +recorded and the demo uses the expressible formulation. + +--- + +## Scope + +**Wave A — unblock (the demo is impossible until these land).** +**S00 wire `(class, environment)` binding routing** (without it a multi-class repo fails closed +in both tiers) · S01 provider host-declaration worked examples + docs + gate (closes G1) · S02 +provider-author guide publishing the frozen wire contract (incl. G2/G3 constraints) · S03 +reference IdP-groups broker provider (Entra + Keycloak adapters). + +**Wave B — repo 1, platform resources (GitLab).** +S04 new sample shapes (Kafka ACL, ArgoCD Application) · S05 `kafka-acl` class + cross-manifest +reference rules + tier-1 ceiling record · S06 `argocd-application` class + rules · S07 repo-1 +assembly (`.assent/` tree, README, demo branches). + +**Wave C — repo 2, terraform (GitHub).** +S08 raw `.tf` sample shape + HCL structuring truth + opaque-change fallback pinned in both +polarities · S09 `tf-module-instance` class + rules · S10 `tf-backend` ungoverned→REVIEW route +(D-063) · S11 repo-2 assembly, tier-1-only README. + +**Wave D — make it real.** +S12 demo CI: both trees run under `task check`, mirror drift gated · S13 `[operator]` publish +the repositories · S14 `[infra-gated · operator]` live tier-2 proof on GitLab. + +**Non-goals** (fenced): **implementing the GitHub adapter** (E10 — this epic consumes it and +supplies its S18 target, it does not build it); **the Rego backend** (E11 — S05 supplies +evidence, nothing more); **new provider builtins** (judgment call (c)); **widening any frozen +schema** — including the HTTP-transport credential channel, which is OQ-32's (e2) and out of +scope until ruled; **generalizing further private shapes** (D-008/D-019/D-029 shapes 4–5 — +these three new shapes are authored generic, not sanitized from `references/`); **a hosted +playground or web sandbox** (a bigger, different product decision); **`assent init`** (does not +exist; the demo copies a `.assent/` tree, as `docs/usage/walkthrough.md` already says). + +**Hard constraint on every authored file, non-negotiable:** AGENTS.md rule 1 / D-002. The +Kafka-ACL, ArgoCD, and Terraform shapes are **generated generic equivalents**, never derived +from `references/**` (gitignored private material). `hack/check-sanitization.sh` must be green, +and no employer, internal system, tenant, realm, cluster, or hostname from those trees may +appear in any form — including as a "renamed" analogue that preserves a recognizable +structure. + +**ADRs**: 0004 (plugin architecture — G4 amends its builtin list in practice), 0015 §7 +(credential isolation — the constraint behind G2), 0017 §6 (fact states, `trusted-full-content`), +0008 (classification/routing — the three classes per repo), 0003 (canonical change model — +opaque-change fallback), 0014 (adopter-test format — tier 1's whole substrate), 0021 + E10 +(repo 2's tier-2 path). **No new ADR is proposed**: this is examples, docs, and a contrib +provider. If OQ-32 resolves to (e2), *that* needs an ADR and it is not this one. + +**Executability**: S00–S12 **`[autonomous]`** — hermetic, no forge, no network (the reference +provider is tested against an `httptest` server and the frozen response schema). **S00 +additionally `[engine-grade · maintainer LGTM]`**: it changes decision routing at the +`cmd/assent` seam, which is exactly the class `/agent-loop-auto` must surface rather than +auto-merge. S13/S14 **`[operator]`** / **`[infra-gated · operator]`**. + +🔴 **PRECONDITION ON EVERY STORY — no DEM story is implementable until its REQs carry +`Test:` / `Verify:` / `Level:` annotations**, per the sibling-epic convention (E10 has 53, +E11 36, E6 42; **DEM currently has 0**). This epic's REQs state intent but name no command, so +"green" is undefined for all 15 stories. That is precisely the condition that produced this +project's documented **six tests that cannot fail** — a story picked up with no definition of +done. The annotation pass is deferred to its own change, not waived; until it lands, DEM-S00 in +particular must not be started, because it is `engine-grade · maintainer LGTM` and is the story +someone will reach for first. + +**Dependency order**: **S00** → S01 → S02 → S03 → {S04 → S05 → S06 → S07} ∥ {S08 → S09 → S10 → +S11} → S12 → S13 → S14. **Do first: S00** — until `(class, environment)` routing exists, both +demo repos fail closed in both tiers and every assembly story is unbuildable; S10 also probes +the same seam. **Then S01** — until a working `.assent/providers/.json` exists anywhere in +the repository, every provider claim in both demos is unverifiable, and S02's guide would +document a path with no worked instance behind it. + +--- + +## Wave A — unblock + +### DEM-S00 — Wire `(class, environment)` binding routing at the `cmd/assent` seam `[autonomous · engine-grade · maintainer LGTM]` + +**As** an adopter whose repo governs more than one kind of resource, **I want** the covering +binding selected by the changed file's class and environment, **so that** a multi-class repo +evaluates at all instead of failing closed. + +**Do this first.** Every assembly story (S07, S11) and the environment-split demonstration +(S09-03) are blocked on it. The *matcher* is cheap — `policy.Config` already carries +`Environments []NamedMatch` and `Classes []NamedMatch` with `Match PathMatch` +(`internal/core/policy/policy.go:196-228`), populated in every shipped example pack, and +`internal/glob.Match` (used by `internal/core/classify/matcher.go:31`) already implements +`*`/`**`. So `internal/core` can stay byte-unchanged. + +**🔴 But the earlier claim that "the data already reaches these call sites" was FALSE, and the +correction changes this story's size.** Found by the independent review of PR #47 and verified +against the tree. It is **not** pure wiring, and an implementer told otherwise will get stuck: + +- **`assent run`: Config is loaded *after* the binding is selected, and only sometimes.** + `selectBinding(rb)` is `run.go:219`; the Config load is step 2b at `run.go:224-237` and is + guarded by `if cfg.config != ""` — `--config` is **optional**. So at the moment routing must + happen, `conf` does not exist. S00 must move the load ahead of selection **and decide + explicitly what happens when `--config` is absent** — a repo with a multi-binding document + and no Config has no routing input, and that case must **fail closed**, never silently + fall back to a collapse or to binding zero. That decision is part of this story. +- **`assent test` never loads a Config at all.** `test.go:67` calls `catalogue.LoadFromDir`, + and `internal/catalogue/catalogue.go:124-127` says so in its own words: *"Config is not among + the D-017 B10 field derivations … so Config is deliberately absent; a later story that needs + config-derived fields adds it then."* `Input` is `{Packs, Bindings}`. **S00 is that later + story.** Extending `catalogue.Input` to carry Config is a deliberate **E6 contract change**, + not wiring — it must be called that, reviewed as that, and it is why this story is + `engine-grade · maintainer LGTM`. +- **`Config.Classes` has zero production readers today** (`grep '\.Classes'` finds only + `Spec.Classes` on profiles, plus tests). It is *precisely* parsed-and-discarded — the opposite + of the original claim. + +**Why this matters more than a wording fix:** tier 1 (`assent test`) is the epic's *primary* +deliverable — the "everybody can see it" claim. An implementer who wires only `run.go` leaves +tier 1 failing closed for both demo repos while the story reads done. + +**REQ-DEM-S00-01** — A changed file's `(class, environment)` is resolved from +`Config.classes[].match.paths` and `Config.environments[].match.paths`, and the covering +`RulesetBinding` entry is selected by that pair. Last-match-wins for environments, matching the +semantics the example packs already document. + +**REQ-DEM-S00-02** — **Fail-closed is preserved wherever routing is genuinely ambiguous — this +is the story's central constraint, not a caveat.** The current code's virtue is that it never +guesses; that must survive. A changed file matching **two classes**, and a resolved +`(class, environment)` pair with **no covering binding**, must both refuse with a named error +rather than pick one. Both pinned as tests. + +**REQ-DEM-S00-03** — Call sites: `run.go:219` and `test.go:87`. **`compare.go:407` stays +fenced** — D-060 fenced it deliberately and this story does not reopen it. + +**REQ-DEM-S00-04** — **D-060's `selectBindingForTest` strictest-collapse is DELETED, not left +as a fallback.** Leaving both paths live is how a fail-closed guarantee is lost quietly: a +future routing bug would silently degrade to the collapse instead of refusing. The D-060 row is +amended to record the supersession. + +**REQ-DEM-S00-05** — `classify`'s reserved classes (`unclassified`, `assent-policy`) are handled +explicitly by the matcher: `assent-policy` keeps GUARD-1 dominance, and `unclassified` must not +resolve to a vouch-carrying binding (ADR-0008 §1's classification stage plus the +2026-07-21 fail-safe-by-construction amendment, enforced by `classify.ValidateRouting` / +`ErrReservedClassRouting` at `internal/core/classify/classify.go:127-145`). **This is the same seam DEM-S10 probes** — +the two stories must agree, and S10 is written against whatever S00 establishes. + +**REQ-DEM-S00-06** — `internal/core` byte-unchanged and `git diff schemas/` == 0, the DoD the +neighbouring epics use. This is a `cmd/assent`-edge change. + +**Given** a repo with `kafka-topic` and `kafka-acl` classes carrying different `require[]`, +**when** an MR changes only a topic file, **then** the `kafka-topic` binding is selected and the +run completes (today: fails closed on both `run` and `test`). +**Given** a changed file matching two class globs, **when** routing runs, **then** it refuses, +naming both classes — never a silent pick. +**Given** a file in an environment with no covering binding, **when** routing runs, **then** it +refuses. + +> **Scope discipline.** This story wires routing and nothing else. It does **not** add class +> inheritance, per-class provider overrides, or glob precedence rules beyond what the schema +> already defines. If routing turns out to need a semantic the schema cannot express, that is a +> finding to record — not a schema widen to slip in here. + +### DEM-S01 — Provider host declarations: worked examples, docs, and a gate `[autonomous]` + +**As** an adopter copying a shipped example pack, **I want** the provider blocks to actually +resolve, **so that** I am not silently running with zero facts. + +**REQ-DEM-S01-01** — Every `providers:` key in every example pack under `examples/packs/**` +has a matching host declaration at `/providers/.json`, valid against +`provider.LoadProviderConfig`, declaring typed `outputs`. + +**REQ-DEM-S01-02** — A test in `examples`' gate walks every `.assent/config.yaml` in the tree, +and **fails** when a declared provider name has no matching `providers/.json`. Both +polarities pinned: a fixture missing its declaration must red the test. + +**REQ-DEM-S01-03** — `docs/usage/` documents the declaration path, that it is read **from the +target ref** (and why: an MR author must not be able to redefine their own fact semantics), +the split between repo-side `{type,url,failure}` and host-side `outputs`/`exec`/`repoFile`/ +`resourceOwner`, and the **silent-skip behaviour** of a missing declaration. + +**REQ-DEM-S01-04** — The `http`-typed example providers either gain a declaration *and* are +re-pointed at the reference broker, or are converted to a builtin. **No example may ship +a provider block that cannot resolve.** ⚠️ **Ordering:** the reference broker is **S03**, which +comes *after* S01 — so this REQ must be satisfied by the **builtin conversion** arm, or S01 +closes only once S03 lands and the epic's stated S01 → S02 → S03 order is a lie. Prefer the +builtin: it keeps S01 self-contained and removes the forward dependency entirely. + +**Given** an example pack with `providers.author`, **when** its `providers/author.json` is +deleted, **then** the gate fails naming the pack and the provider. +**Given** the shipped packs as committed, **when** the gate runs under `task check`, **then** +it passes. + +> **Note on the silent skip itself.** `provider_host.go:83-87` returns `continue` on a missing +> declaration, with a stated rationale (inventing `unavailable` keys would change CEL from +> "absent" to "false" — a real and correct concern). This story does **not** change that +> behaviour; it makes the condition impossible in shipped examples and discoverable in docs. +> Whether a *configured* provider with no declaration should be an operator error rather than a +> skip is a decision-path question and belongs in its own reviewed lane, not here. + +### DEM-S02 — Provider-author guide `[autonomous]` + +**As** a platform engineer at an organization assent has never heard of, **I want** a document +I can implement a provider from, **so that** I do not have to read Go source to learn the wire +format. + +**REQ-DEM-S02-01** — `docs/usage/providers.md` (nav-linked) documents the request/response +envelopes **by reference to the frozen schemas**, never by restating them (restated schemas +drift; `docs/planning/provider-contract.md`'s 45 lines about `maxAge` are not a wire contract +and are cross-linked, not replaced). + +**REQ-DEM-S02-02** — The fail-closed state table is documented with the adopter-facing +consequence of each state, plus the two rules a provider author will otherwise get wrong: +**echo `queryId`**, and **derive every timestamp from the host-pinned `asOf`** — never from a +provider wall clock (hard rule 7). + +**REQ-DEM-S02-03** — The **credential constraint (G2)** is stated plainly: no header, token, or +client certificate can reach an HTTP provider, and `(?i)TOKEN|SECRET` env/argv names are +refused on exec. The broker pattern is documented as the shape that works, with the reasoning +(ADR-0015 §7), and cross-links **OQ-32**. + +**REQ-DEM-S02-04** — The exec digest-pin re-pinning burden (G3) is stated, with the HTTP/broker +transport recommended for third-party providers. + +**Given** a reader with no access to this repository's Go source, **when** they follow the +guide, **then** they can produce a response the host classifies `resolved` — pinned by a test +that runs the guide's own copy-pasteable example payload through `ResolveFactsChecked`. + +### DEM-S03 — Reference IdP-groups broker provider `[autonomous]` + +**As** an adopter on Entra ID or Keycloak, **I want** a working provider I can copy, **so that** +swapping user resolution is an afternoon, not a project. + +**REQ-DEM-S03-01** — `contrib/providers/idp-groups/` ships a single small Go binary serving +`POST /` with a `FactResponse`, structured as an **IdP-agnostic core** plus two thin adapters: +**Entra ID** (`transitiveMemberOf`-shaped) and **Keycloak** (`users//groups`-shaped). + +**REQ-DEM-S03-02** — The broker holds the IdP credential itself; **assent passes none**. The +README states the deployment contract (loopback/sidecar or mesh-terminated mTLS) and why. + +**REQ-DEM-S03-03** — Every response is validated against +`schemas/provider/v1alpha1/response.schema.json` in test, and every timestamp derives from the +request's `asOf`. Tests are hermetic (`httptest` upstreams); **no live IdP is contacted in CI**. + +**REQ-DEM-S03-04** — Failure paths are pinned: upstream 5xx/timeout → the host classifies +`unavailable`; unknown subject → `unavailable` with a reason, **never `resolved` with `[]`** +(the REQ-E5-S06-02 rule — an empty group set is a *false* authorization answer, not a missing +one). + +**REQ-DEM-S03-05** — `contrib/` is explicitly **not** part of the decision path and not covered +by `API_STABILITY.md`; a `README` says so, and no `internal/core` package may import it. + +**Given** an Entra-shaped upstream returning two groups, **when** the host queries the broker, +**then** exactly one `resolved` fact with the sorted group set and `expiresAt = asOf + maxAge`. +**Given** the upstream times out, **when** the host queries, **then** exactly one `unavailable` +fact — and a rule controlling authorization on it fails closed. + +--- + +## Wave B — repo 1: platform resources (GitLab) + +### DEM-S04 — New sample shapes: Kafka ACL + ArgoCD Application `[autonomous]` + +**REQ-DEM-S04-01** — `examples/repos/` gains generated-generic Kafka ACL manifests (principal, +resource type/name/pattern, operation, permission, environment) and Argo CD `Application` +manifests (project, source repo/path/targetRevision, destination server/namespace, sync +policy), each one entry per file, with an owner field. +**REQ-DEM-S04-02** — `hack/check-sanitization.sh` green; **no content derived from +`references/**`** (D-002). Reviewed against that tree for structural resemblance, not just +string matches. +**REQ-DEM-S04-03** — `examples/repos/README.md`'s shape table gains the two rows; the D-029 +deferred-shapes rows stay untouched (these are new generic shapes, not the deferred private +generalizations). + +### DEM-S05 — `kafka-acl` class + cross-manifest reference rules `[autonomous]` + +**REQ-DEM-S05-01** — Rules: the ACL's referenced **topic must exist** on the merge result; the +requesting author's team must **own the referenced topic** (via `builtin/resource-owner`); the +principal must match the requesting team's allowed principal pattern; `permission: allow` on a +prod `*`-pattern resource is blocked. +**REQ-DEM-S05-02** — Both polarities per rule, as directory cases with `facts.yaml`. +**REQ-DEM-S05-03** — **Tier-1 ceiling record.** For each rule, record whether it is +CEL-expressible under ADR-0013 and, where it is not, the concrete shape that defeats it. The +record is written into this spec directory and is a **named input to E11-S01** (D-141). +**REQ-DEM-S05-04** — 🔴 **RESCOPED TO TIER 2 — as originally written this was unbuildable.** +The intent stands: a referenced topic *deleted in the same changeset* the ACL references must +not evaluate as present. But **the evaluation unit is one file**: `assent run` takes exactly one +`--subject file:` (`cmd/assent/run.go:266`) and diffs that file alone (`:289`); the +changed-file fold (`:316-336`) propagates only `classify.ClassAssentPolicy` and opacity, and +`adoptertest.Case` is singular (`File string; Base, Head []byte`, +`internal/adoptertest/adoptertest.go:150-160`). No single evaluation can contain both files, and +there is no cross-subject aggregation at the run seam. **The escape hatch is real but tier-2 +only:** `builtin/repo-file` reads the *merged-result checkout* (ADR-0008 §4, +`OpenRepoRoot` in `internal/provider/builtin/repo_file.go`), so same-MR presence **is** +resolvable — under `--checkout`, i.e. DEM-S14 `[infra-gated · operator]`. **Anti-tautology +clause:** satisfying this with a hand-authored `facts.yaml` value asserting the topic is absent +proves nothing and does **not** close the REQ. + +**Given** an MR adding an ACL for a topic owned by another team, **when** assent evaluates, +**then** BLOCK with a finding naming the owning team. +**Given** an MR adding a dev ACL for a topic the author's team owns, **when** assent evaluates, +**then** APPROVE. + +### DEM-S06 — `argocd-application` class + rules `[autonomous]` + +**REQ-DEM-S06-01** — Rules: destination `namespace` within the team's allow-list +(`builtin/repo-file` walk-up, most-specific-first); `project` within the allow-list; source +repo within the allow-listed org; `syncPolicy.automated.prune: true` on a prod Application → +REVIEW; removing an Application in prod → no-destruction BLOCK. +**REQ-DEM-S06-02** — Both polarities per rule. +**REQ-DEM-S06-03** — The per-environment allow-list demonstrates `repo-file` most-specific-first +resolution across at least two levels, with the walk-up visible in the fixture layout. +🔴 **This is a tier-2 requirement and must be labelled as one.** `assent test` cannot exercise +it: the E6 fence (`cmd/assent/test_provider_fence_test.go:69-80`) keeps tier 1 on +`facts.yaml` stubs, so **nothing walks up at tier 1**. Satisfying this REQ with an authored +`facts.yaml` value that merely *looks* like a resolved owner is a **test that cannot fail** and +does not satisfy it. Either demonstrate the walk-up at tier 2 (DEM-S14), or state plainly in the +fixture and the README that tier 1 shows a stubbed fact and the resolution itself is tier 2. + +### DEM-S07 — Repo 1 assembly `[autonomous]` + +**REQ-DEM-S07-01** — `examples/demo/assent-demo-platform/` is a complete repo root: governed +content, `.assent/{config.yaml,bindings.yaml,packs/**,tests/**,providers/*.json}`. +**REQ-DEM-S07-02** — `assent lint .` clean and `assent test .` green; `assent test . --coverage` +shows every rule covered in **both** polarities. +**REQ-DEM-S07-03** — README leads with the **60-second tier-1 path** (clone → `assent test .`), +then the tier-2 live path, then the L0→L3 user-resolution ladder with the two-file L2→L3 diff +shown inline. **The ladder must carry its tier column** — per the fence note above, **L1 and up +are tier 2 only**; a README presenting the ladder without that qualifier implies live provider +resolution at tier 1 and is a story failure. +**REQ-DEM-S07-04** — Prepared demo branches, each named for its outcome and each an entry in +the test suite so it cannot silently stop reproducing: at minimum +`demo/approve-add-dev-topic`, `demo/review-prod-partition-shrink`, `demo/block-foreign-team-acl`, +`demo/block-prod-app-delete`. + +--- + +## Wave C — repo 2: terraform (GitHub) + +### DEM-S08 — Raw `.tf` shape + HCL structuring truth `[autonomous]` + +**REQ-DEM-S08-01** — Generated-generic Terraform stacks: a blessed-module instantiation per +env, with `source`/`version` pins and a small set of governed inputs. D-002 as in S04. +**REQ-DEM-S08-02** — **Written truth about what the fact model can structure in HCL** — which +constructs yield bound entries and which fall back to opaque. Determined by running the code, +not by reading it, and recorded with the commands used. +**REQ-DEM-S08-03** — The **opaque-change fallback is pinned in both polarities**: a structured +`.tf` edit evaluates on bound fields; an edit the model cannot structure routes to REVIEW and +**never** to APPROVE. + +> This story is deliberately allowed to discover that raw HCL structures worse than expected. +> If so, the finding is recorded and repo 2 leans harder on `.tfvars` for structured rules +> while keeping `.tf` as the opaque-fallback demonstration — which is itself worth showing. +> **Silently dropping `.tf` is not an option**; it was explicitly requested. + +### DEM-S09 — `tf-module-instance` class + rules `[autonomous]` + +**REQ-DEM-S09-01** — Rules: module `source` within the allow-listed registry; `version` pinned +exactly (no range) and within the allowed band; instance-size / replica-count within the +per-environment band; a prod change exceeding the bounded-change budget → REVIEW. +**REQ-DEM-S09-02** — Both polarities per rule. +**REQ-DEM-S09-03** — Environment split proven: the identical diff APPROVEs in `dev` and REVIEWs +or BLOCKs in `prod`, from one changeset — the clearest single demonstration of binding scope. +**Depends on DEM-S00 and is currently impossible in either tier**: `run` fails closed on >1 +binding, and `test` collapses a multi-binding document to its strictest (D-060). A +decision-*flipping* environment split has never been exercised by any shipped example — D-060 +recorded the corpus split as decision-neutral — so this requirement is also S00's sharpest +acceptance test, not merely a demo nicety. + +### DEM-S10 — `tf-backend` ungoverned → REVIEW `[autonomous]` + +**REQ-DEM-S10-01** — `stacks//backend.tf` is matched by **no** class. The two cases have +**different governing mechanisms and must be cited separately** — conflating them is how a +guarantee gets over-claimed: +- **Delete** — an unmatched whole-file delete escalates fail-safe to REVIEW + (**D-063**, `aggregate.unmatchedDelete` / `fileEvent.unmatchedDelete`). Operator-confirmed; + no relaxation to APPROVE authorized. +- **Edit** — governed by **ADR-0008 §1**'s implicit `unclassified` class (`classify.go:18-20`), + which no vouch rule + may match, so an unmatched edit cannot be vouched for. + +**REQ-DEM-S10-02** — **What actually happens to an unmatched EDIT at the `run` seam is +DETERMINED BY RUNNING THE CODE, not asserted** — the same discipline as S08's HCL truth, and +required here because `run.go:487-492` records `unclassified` routing as *"not wired in this +lane"*. The observed behaviour is recorded with the commands used. If it is anything other than +a refusal or REVIEW, **that is a finding, not a demo feature**, and it is logged rather than +worked around. Depends on DEM-S00, which owns the same seam (REQ-DEM-S00-05). + +**REQ-DEM-S10-03** — 🔴 **RESCOPED TO TIER 2 — as originally written this was unbuildable.** +The intent stands: in a changeset touching **both** a governed `.tfvars` and `backend.tf`, the +ungoverned file must dominate and an otherwise-clean governed change must not carry the +ungoverned one to APPROVE. But per REQ-DEM-S05-04's note the evaluation unit is **one file**, so +no single tier-1 evaluation can contain both — and there is no unmatched-**edit** analogue of +the delete escalation (`internal/core/aggregate/coverage.go:251-253` gates on +`ch.Path == "" && ch.Kind == delete`). Demonstrate this at tier 2 (DEM-S14) against a real MR +touching both files, or state the single-subject constraint and drop the claim. **Anti-tautology +clause:** two separate single-file evaluations run side by side do **not** demonstrate +domination — domination is a property of aggregating them, which is the thing that does not +exist. Building that aggregation is engine work this epic explicitly fences out; if S10 finds it +necessary, that is a **finding to log**, not a demo feature to add. + +### DEM-S11 — Repo 2 assembly `[autonomous]` + +**REQ-DEM-S11-01** — `examples/demo/assent-demo-terraform/` complete, `lint` clean, `test` +green, both-polarity coverage, as S07. +**REQ-DEM-S11-02** — README leads with tier 1 and **states in its first screenful that the live +GitHub tier is pending E10-S18**, linking the epic. No workflow file that would appear to run +assent on PRs may be committed before the adapter exists. +**REQ-DEM-S11-03** — Prepared demo branches as S07, including a `demo/review-opaque-backend-change`. + +--- + +## Wave D — make it real + +### DEM-S12 — Demo CI + mirror-drift gate `[autonomous]` + +**REQ-DEM-S12-01** — Both demo trees run `assent lint` + `assent test` under `task check` and in +`verify`. A demo that stops reproducing **reds assent's own build**. +**REQ-DEM-S12-02** — Every prepared demo branch's expected outcome is an adopter-test case, so +the README's promised decisions are gated, not asserted. +**REQ-DEM-S12-03** — Once S13 lands, a check diffs the in-tree trees against the published +repos and fails on drift; until then it is a no-op with the reason recorded (**no silent +skip** — a gate that cannot fail must announce that it is inert). + +### DEM-S13 — Publish the repositories `[operator]` + +**REQ-DEM-S13-01** — 🔴 Operator creates `PlatformRelay/assent-demo-platform` (mirrored to +GitLab) and `PlatformRelay/assent-demo-terraform`, seeds `main` from the in-tree trees, pushes +the demo branches. **Requires explicit authorization** — judgment call (d). +**REQ-DEM-S13-02** — `README.md` and the docs site link both repos with the tier each supports. +**REQ-DEM-S13-03** — The mirror procedure is a script in `hack/`, not a remembered sequence. + +### DEM-S14 — Live tier-2 proof on GitLab `[infra-gated · operator]` + +**REQ-DEM-S14-01** — assent runs on real MRs in `assent-demo-platform` producing at least one +APPROVE (with a real SHA-pinned merge), one REVIEW (with a resolvable thread), and one BLOCK. +**REQ-DEM-S14-02** — DecisionRecords retained as evidence under `docs/decisions/evidence/`, +mirroring the D-042 shape. +**REQ-DEM-S14-03** — GitHub tier 2 is **explicitly deferred to E10-S18**, which retargets its +live adoption proof at `assent-demo-terraform` instead of a throwaway repository. + +--- + +## Exit gate + +The epic is done when: `(class, environment)` routing is wired with ambiguity still failing +closed and D-060's collapse deleted (S00); both demo trees are `lint`-clean and `test`-green +under `task check` +with both-polarity coverage on every rule (S12); a reader with **no forge account, no token, +and no network** can clone either repo and see real decisions (tier 1); every `providers:` key +in the repository has a resolving host declaration and the path is documented (S01/S02); the +reference broker provider passes its hermetic suite against the frozen response schema (S03); +`hack/check-sanitization.sh` is green over all newly authored shapes (D-002); the E11 tier-1 +ceiling record exists (S05); both repositories are published and linked (S13); and the GitLab +live tier-2 proof is recorded (S14) with the GitHub tier honestly marked pending E10-S18.