Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ repository still gets a decision, never by following the link; no release carrie
- :memo: docs(openspec): amend AUD2-S03 to the shipped pin and log D-153 (SEC-03)
- :memo: docs(audit): document the AUD2 gate and correct stale line counts (F4, F6)
- :memo: docs(backlog): mark P5-AUD2 complete (S01-S05 landed)
- :memo: docs(e11): record the tier-1 CEL ceiling and narrow E11 (E11-S01)
- :memo: docs(e11): strike set-difference unconditionally; make the graph shape contingent
- :memo: docs(e11): Shape D justifies E11 unconditionally — an encoded adjacency CEL cannot decode (F01)
- :memo: docs(e11): Shape D rests on no-recursion alone — the decode barrier was false (F-01)
- :memo: docs(e11): delete the false cost claim; reason 1 carries Shape D alone (F3-01)

### Features
- :sparkles: feat(audit): measure REL-03/REL-07 by real mutation run, not by name
Expand Down
1 change: 1 addition & 0 deletions docs/decisions/decisions.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/planning/open-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@
| OQ-30 | **RESOLVED (D-148, 2026-08-16): (b) — keep the guard skipped on `pull_request`; the real mechanism (merge-direction-dependent ordering hazard) is now recorded in D-125/D-136.** Analysis retained below for the record. Original text: 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 | **RESOLVED (D-146, 2026-08-16): (a) — "zero forge writes" stays absolute; the BLOCK is surfaced via a required CI job status reading the already-emitted `DecisionRecord`, not via a forge write.** Analysis retained below for the record. Original text: 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 | **RESOLVED (D-147, 2026-08-16): (b) — add a host-side secret resolver (process env / file path / hosted store); repo-side config gains only an opaque, host-allowlisted reference name, never a literal credential or URL pairing. ADR amending ADR-0015 §7 required before code.** Analysis retained below for the record. Original text: 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` |
| OQ-35 | **`entry` / `oldEntry` bind whole-entry value trees under `assent test` but a bare scalar under `assent run` — do we extend the binding to the run path, or narrow the documented contract?** `docs/planning/predicate-scope.md` describes `entry` as "head-state value tree of the containing `EntryRef`" with no qualifier, and `internal/core/aggregate/evaluate.go` `bindLeafActivation` binds `toCEL(entryOr(ch.Entry, ch.New))` — falling back to the change's **scalar** `new`/`old` when `ch.Entry` is nil. The **only** writer of `EvalChange.Entry` is `internal/adoptertest/entrytree.go` `populateEntries` (called from `adoptertest.go:288`); `internal/evaldecode.BuildEvaluationInput` — the sole production builder, reached from `cmd/assent/evaldecode.go` — never sets it, and `cmd/` contains no reference to `EntryConfig`, `DiffEntries` or `change.Entries` at all (`assent run` calls the document-mode `change.Diff` via `changeSetForGoverned`). **Consequence:** a rule such as `oldEntry.acls.filter(a, !(a in entry.acls)).size() == 0` passes in `assent test` and, in production, hits a no-such-attribute error on a scalar → `predicate.error` → REVIEW. The direction is fail-safe, so this is not urgent and not a release blocker; what it is not is *documented*, and an adopter who validates a pack with `assent test` has no signal that the rule will never fire in `assent run`. **Ruling needed (deliberately not taken):** **(a)** extend entry reconstruction to the `assent run` path so the two agree — the honest fix, but it puts collection-mode entry derivation on the live decision path and needs its own story; **(b)** state the limitation in `predicate-scope.md` and add an `assent lint` hard error for a rule that navigates `entry`/`oldEntry` as an object — cheap, keeps the contract truthful, costs adopters the capability; **(c)** leave as is (rejected on sight — it is a silent test/production divergence). **Not to be resolved by a fixture that only runs under `assent test`** — that is precisely the "test that cannot fail" this repo's reviews keep finding. | **Gates nothing.** D-156 records that both resolutions strike the set-difference shape (extending makes CEL express it; narrowing makes it an input-availability failure a Rego module inherits unchanged), and the graph-relationship shape needs no entry tree — its adjacency arrives as a flat `cardinality: set` fact. What is actually at stake is a silent `assent test` / `assent run` divergence: a pack an adopter validates green can contain a rule that never fires in production. Blocks nothing today. | Found writing [the tier-1 ceiling record](rego-tier-ceiling.md) (E11-S01); recorded in [D-156](../decisions/decisions.md). Evidence: `internal/core/aggregate/evaluate.go` (`bindLeafActivation`, `entryOr`), `internal/adoptertest/entrytree.go`, `internal/evaldecode/evaldecode.go` `BuildEvaluationInput`, `cmd/assent/run.go:293` `changeSetForGoverned`, `docs/planning/predicate-scope.md` |
| OQ-36 | **The frozen provider declaration has no object/map type, yet the authoring surface and `builtin/repo-file` together permit a mapping-valued fact and dynamic navigation into it — is a mapping-shaped fact value in-contract or out?** `schemas/provider/v1alpha1/response.schema.json` freezes `declaration.type` to `boolean \| string \| integer \| principal` and `cardinality` to `single \| set`; `value` itself carries **no** JSON-Schema type constraint ("shape governed by declaration.type/cardinality") and `provider.ResolveFactsChecked` cross-checks the *declaration*, never the value. Meanwhile `builtin/repo-file` maps each requested output to a **top-level key** of the resolved file via `readMapping` (`map[string]any` from `yaml.Unmarshal`), so a top-level key holding a mapping is emitted verbatim; `factsToCEL`/`toCEL` bind it as a CEL map; and `internal/lint/facts_ref.go`'s D-051 shape check permits arbitrary navigation *past* `.value` (`selectChainFields` stops the chain at an index), so `facts.registry.topics.value[string(new)].retentionMs` is lint-clean and compiles. **So a keyed cross-manifest join works today under a declaration that cannot describe it.** This is not hypothetical polish: D-156 struck the cross-manifest shape from E11's scope partly on the strength of that spelling, and it is the one leg of that strike resting on an undeclarable value. **Ruling needed (deliberately not taken):** **(a)** add `object` (or `map`) to the declaration `type` enum — an announced additive change to a frozen schema, needs its own openspec change and `API_STABILITY.md` entry, and widens what a hostile provider may inject into the decision path; **(b)** state that a fact value must match its declared scalar/set shape and enforce it host-side in `ResolveFactsChecked` (a mismatch → `invalid`, value dropped) — fail-safe and closes the gap, but it **breaks** the keyed-join spelling above and returns the cross-manifest sub-shape B2 to the ceiling; **(c)** document the status quo as intentional — the declaration describes the *leaf* type and navigation into a container is the author's risk (weakest: it makes the declaration cross-check advisory for exactly the values that carry the most structure). | Not a release blocker — the current behaviour is fail-safe either way (an absent key errors → REVIEW). **Gates nothing in D-156:** it touches only the *second* spelling of cross-manifest sub-shape B2, which is struck on its first spelling (a purpose-built provider) regardless, and the graph-relationship shape needs only a flat `cardinality: set` fact. Relevant to anything that later publishes the provider-author guide (DEM-S02). | Found writing [the tier-1 ceiling record](rego-tier-ceiling.md) (E11-S01); recorded in [D-156](../decisions/decisions.md). Evidence: `schemas/provider/v1alpha1/response.schema.json` (`declaration.type`/`cardinality`, unconstrained `value`), `internal/provider/resolve.go` `ResolveFactsChecked`, `internal/provider/builtin/repo_file.go` (`answerRepoFile`, `readMapping`), `internal/core/aggregate/evaluate.go` `factsToCEL`/`toCEL`, `internal/lint/facts_ref.go` (`checkFactsShape`, `selectChainFields`) |
Loading