diff --git a/API_STABILITY.md b/API_STABILITY.md index 6c9ca62..d1759c3 100644 --- a/API_STABILITY.md +++ b/API_STABILITY.md @@ -48,15 +48,20 @@ replace — the broader hard-error table in [`docs/planning/lint-hard-errors.md` Executable guards: `go test ./schemas/... -run TestDoNotGeneralize`. Removing a schema guard so one of these fixtures validates is a failing regression, not a silent policy expansion. -> **Match-domain implementation status (E1-S06).** The four match-domain *primitives* shipped in -> `internal/core/classify/matcher.go` are `files` / `values.pointers` / `valueChanges` / -> **`entryEvents`**. `entryEvents` matches collection-*entry* identity churn (a keyed map/list -> entry added/removed/renamed within one file, via the E1-S05 `EntryRef`); it is deliberately a -> distinct domain from ADR-0017 §5's **`fileEvents`**, which denotes ADR-0003's whole-file -> git-detected add/delete/rename. Whole-file `fileEvents` is **not yet implemented** (deferred to a -> fast-follow after E1-S08, which first enumerates the MR's full changed-file set). The frozen §5 -> vocabulary above is unchanged; this note records that the shipped primitive set substitutes -> `entryEvents` for `fileEvents` until the latter lands. +> **Match-domain implementation status (E1-S06, E-FILEEVENTS).** Alongside the ADR-0017 §5 +> vocabulary, `internal/core/classify/matcher.go` also ships **`entryEvents`** — collection-*entry* +> identity churn (a keyed map/list entry added/removed/renamed within one file, via the E1-S05 +> `EntryRef`). It is deliberately a distinct domain from §5's **`fileEvents`**, which denotes +> ADR-0003's whole-file git-detected lifecycle. +> +> Whole-file `fileEvents` **is implemented, for a narrowed kind set**: `internal/change.FileEvent` +> mints whole-file lifecycle changes and the loader accepts `match.fileEvents.kinds` ⊆ +> **`{add, delete}`**. `modify` and `rename` have no minting path, so a rule naming either is +> **rejected at load** with a located error (`internal/core/policy/loader.go`) rather than left to +> match nothing — closing a vacuous-cover fail-open. This loader-level narrowing sits on top of the +> frozen schema, which still accepts the full `add`/`modify`/`delete`/`rename` enum: widening the +> accepted kinds later is additive and needs no `apiVersion` bump; the frozen §5 vocabulary above is +> unchanged. ## Portability notes (validators) diff --git a/README.md b/README.md index 9dd3fb9..b9bd127 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ it destructive, which policy applies. assent encodes that reasoning as policy so - **Semantic diffs** — JSON, YAML, and HCL/tfvars parse into field-level adds/modifies/deletes, not line noise ([ADR-0003](docs/adr/0003-canonical-change-model.md)). - **Testable policies** — fixture changes in, expected decision out; policies without tests - are a lint error ([ADR-0014](docs/adr/0014-policy-test-harness.md)). + are a lint error ([ADR-0014](docs/adr/0014-adopter-test-format.md)). ## How it works @@ -65,23 +65,31 @@ See [system context](docs/architecture/c4-context.md) for the full C4 diagram. ## Quick start -Install a stamped binary ([install guide](docs/usage/install.md)): +Install from source ([install guide](docs/usage/install.md)): ```bash go install github.com/PlatformRelay/assent/cmd/assent@latest assent version ``` -Or verify a release archive with the checksum script — details in -[docs/usage/install.md](https://platformrelay.github.io/assent/usage/install/). +`go install` compiles without link-time stamping, so the binary it produces reports +`assent 0.0.0-dev` — even when you pin a tag (`@v0.1.0`). For a **version-stamped** +binary take the Homebrew tap or a release archive: goreleaser injects the version +(`-X main.version`) and the archives are checksum- and signature-verifiable. Both +routes are in [docs/usage/install.md](https://platformrelay.github.io/assent/usage/install/). -Lint and test policies locally: +Lint and test policies locally. Both commands take the **repository root** — `assent` +appends `.assent` itself, so passing `.assent/` makes it look for `.assent/.assent`: ```bash -assent lint .assent/ -assent test .assent/ +assent lint . +assent test . ``` +No repo of your own yet? A clone of this one ships runnable sample policy trees; run the +two commands above from `examples/packs/service-catalog` (that is the fixture +`hack/docs/readme_smoke_test.sh` executes this block against). + Developers: gates live in the [`Taskfile`](Taskfile.yml): ```bash diff --git a/docs/adr/0020-forge-snapshot-changed-file-completeness.md b/docs/adr/0020-forge-snapshot-changed-file-completeness.md index cc1ecfa..059bdf1 100644 --- a/docs/adr/0020-forge-snapshot-changed-file-completeness.md +++ b/docs/adr/0020-forge-snapshot-changed-file-completeness.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| **Status** | Proposed | +| **Status** | Accepted (D-119 — implemented in P5-AUD-S01) | | **Date** | 2026-08-06 | | **Deciders** | Konrad Heimel | | **Context links** | [ADR-0008](0008-change-classification-routing-scope.md) §4 · [ADR-0015](0015-trust-boundaries-merge-integrity.md) §1 · [ADR-0017](0017-contract-model-obligations.md) §1 (honest capability gaps) · D-042 · D-076 · D-077 · REL-07 (PROJECT-AUDIT-2026-08-06) | diff --git a/docs/adr/README.md b/docs/adr/README.md index 9c64619..8c80239 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,4 +31,4 @@ supersessions by ADR-0016/0017 are noted on each ADR's status line (not full | [0017](0017-contract-model-obligations.md) | Contract model: governed subjects, required obligations, typed facts, preconditioned reconciliation | Accepted | | [0018](0018-policy-lifecycle-phase-profile-comparison.md) | Policy lifecycle — phase, profiles, comparison | Accepted (D-030) | | [0019](0019-publication-marker-reconciliation-protocol.md) | Publication marker + reconciliation protocol (database-free) | Accepted (D-030) | -| [0020](0020-forge-snapshot-changed-file-completeness.md) | Forge snapshot changed-file completeness contract | Proposed | +| [0020](0020-forge-snapshot-changed-file-completeness.md) | Forge snapshot changed-file completeness contract | Accepted (D-119) | diff --git a/docs/api-stability.md b/docs/api-stability.md index 5efaf64..541f5d2 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -48,15 +48,20 @@ replace — the broader hard-error table in [`docs/planning/lint-hard-errors.md` Executable guards: `go test ./schemas/... -run TestDoNotGeneralize`. Removing a schema guard so one of these fixtures validates is a failing regression, not a silent policy expansion. -> **Match-domain implementation status (E1-S06).** The four match-domain *primitives* shipped in -> `internal/core/classify/matcher.go` are `files` / `values.pointers` / `valueChanges` / -> **`entryEvents`**. `entryEvents` matches collection-*entry* identity churn (a keyed map/list -> entry added/removed/renamed within one file, via the E1-S05 `EntryRef`); it is deliberately a -> distinct domain from ADR-0017 §5's **`fileEvents`**, which denotes ADR-0003's whole-file -> git-detected add/delete/rename. Whole-file `fileEvents` is **not yet implemented** (deferred to a -> fast-follow after E1-S08, which first enumerates the MR's full changed-file set). The frozen §5 -> vocabulary above is unchanged; this note records that the shipped primitive set substitutes -> `entryEvents` for `fileEvents` until the latter lands. +> **Match-domain implementation status (E1-S06, E-FILEEVENTS).** Alongside the ADR-0017 §5 +> vocabulary, `internal/core/classify/matcher.go` also ships **`entryEvents`** — collection-*entry* +> identity churn (a keyed map/list entry added/removed/renamed within one file, via the E1-S05 +> `EntryRef`). It is deliberately a distinct domain from §5's **`fileEvents`**, which denotes +> ADR-0003's whole-file git-detected lifecycle. +> +> Whole-file `fileEvents` **is implemented, for a narrowed kind set**: `internal/change.FileEvent` +> mints whole-file lifecycle changes and the loader accepts `match.fileEvents.kinds` ⊆ +> **`{add, delete}`**. `modify` and `rename` have no minting path, so a rule naming either is +> **rejected at load** with a located error (`internal/core/policy/loader.go`) rather than left to +> match nothing — closing a vacuous-cover fail-open. This loader-level narrowing sits on top of the +> frozen schema, which still accepts the full `add`/`modify`/`delete`/`rename` enum: widening the +> accepted kinds later is additive and needs no `apiVersion` bump; the frozen §5 vocabulary above is +> unchanged. ## Portability notes (validators) diff --git a/docs/decisions/decisions.md b/docs/decisions/decisions.md index 8714c0e..d89b4ae 100644 --- a/docs/decisions/decisions.md +++ b/docs/decisions/decisions.md @@ -128,3 +128,4 @@ project/process decisions. | D-121 | 2026-08-06 | **ARCH-04 — canonical-hash split codified; `assent-jcs-v1` wired into the replay-bundle digest (corrects D-114 drift).** Rule: digests over BYTE artifacts (policySha over policy bytes, marker occurrence over judged head bytes, marker decision over emitted DecisionRecord bytes, toolDigest) are raw `sha256:` — byte identity is the point; digests over SCHEMA-OWNED JSON DOCUMENTS that consumers re-parse and re-verify use `internal/core/hash.Digest` (ADR-0017 §9 domain separation) with the schema `$id` as domain. Exactly one digest switches now: `compare.ReplayBundleDigest` → `hash.Digest("https://assent.dev/schemas/decision/v1alpha1/replay-bundle.schema.json", raw)` (D-114 claimed this; implementation used undomained `sha256(json.Marshal(decoded))`). Migration (pre-v1, same commit): regenerate every `replayBundleDigest` in `examples/comparison/*/suite.yaml`; caseIds/bundles unchanged (D-113 immutability preserved — algorithm versioned by this row). ADR-0019 marker grammar untouched. Revert: restore undomained digest + old corpus pins. | | D-122 | 2026-08-06 | **REL-08 — DecisionRecord emit precedes Reconcile; emit is atomic and fail-closed.** New invariant: NO forge write without a schema-valid, durably-emitted DecisionRecord. Order in `orchestrate`: build → marshal → schema-validate → EMIT (`--emit`: write `.tmp` same-dir + `os.Rename`; stdout: unchanged) → Reconcile → summary. Emit failure → hard error, zero forge writes. Pure reordering: `recordJSON` is fully determined pre-Reconcile (receipt lives in the summary line, not the record) — byte-identical records, marker digests and determinism gate unaffected. Rejected: post-reconcile record stamping (breaks record byte-stability vs marker `decision` digest). Revert: move emit back below Reconcile. | | D-123 | 2026-08-06 | **ARCH-01 — boundary enforcement automated (depguard + extended purity walk); ADR-0011 Amendment 3 truths the "arch-lint enforced" claim.** Two layers: (1) golangci `depguard` deny-rules — `internal/core/**`, `internal/change/**`, `internal/glob`, `internal/lint`, `internal/catalogue`, `internal/evaldecode`, `internal/compare`, `schemas/**` may import none of `internal/forge/**`, `internal/render/**`, `cmd/**`, `net/**`; (2) `TestCorePurity` walk extends to `../evaldecode`, `../compare`, and `../../schemas` (call-level: `time.Now`/`os.Getenv`/`os.Environ`/rand/net, adversarial self-test retained). Scope note: this EXTENDS the AGENTS.md rule-7 pure tree — `internal/evaldecode` (engine input decode) and `internal/compare` (D-116/D-117 gate determinism) join the determinism guard; `schemas` is embedded compile-time authority. Acceptance: a synthetic violating import/call fails CI both ways. Revert: drop depguard rules + walk dirs and re-amend ADR-0011. | +| D-124 | 2026-08-07 | **AUD-S06 residual — the two docs truth-lag gates exist but are UNWIRED; wiring is assigned to Lane B.** `hack/docs/readme_smoke_test.sh` (REQ-AUD-S06-01, executes every README quick-start command) and `hack/docs/truthlag_pins_test.sh` (REQ-AUD-S06-02, 18 grep/diff pins over DOC-05/06/09/10/11 + the ADR status index) are green and mutation-proven, but nothing invokes them: `Taskfile.yml` and `.github/workflows/**` belong to **Lane B** (AUD-S02/S03/S09/S14), so AUD-S06 could not add them. **Consequence, stated plainly: until they are wired, a future README or docs edit reopens DOC-07 (a quick-start that exits 2) or any pinned claim with NOTHING going red** — the mechanism is a manual gate, which is the same class of defect AUD-S06 exists to close. Assignment: Lane B adds a `docs-gates` task (`bash hack/docs/readme_smoke_test.sh && bash hack/docs/truthlag_pins_test.sh`) as a `check:` dependency, following the `hack/compare/exitgate_test.sh` precedent (D-118), alongside its AUD-S09/S14 workflow work. **Extend when wiring** (known pin gaps, both accepted for now): (a) the DOC-09 walkthrough check asserts banner PRESENCE, not polarity — flipping a step's `Planned` to `Shipped` stays green; (b) the DOC-05 link-resolution loop is scoped to `README.md`, so the relative links in `examples/README.md` are unpinned; (c) AUD-S05's `TestNoStaleProductClaims` (`cmd/assent/main_help_test.go`, Lane A5's file) walks only `cmd/`, `internal/` and `docs/` — markdown under `hack/`, `.github/` and `test/` is grepped by no pin at all. **Known unfixed truth-lag, deliberately not corrected in AUD-S06 to keep the reviewed diff narrow — fix when next editing these files:** (i) `docs/planning/meta-plan.md` closes the Phase-5 epic table with "Ordering constraint: E7 starts early (alongside E1)", directly under the new heading asserting the table is the numbering that actually executed — E7 in fact landed after E6 (E6 tip `ec91226` is an ancestor of E7-S08 `f27457d`, both 2026-08-04); (ii) `docs/usage/install.md` credits a stamped version to "the Homebrew **bottle**", but `.goreleaser.yaml`'s `brews:` block publishes a **Formula** whose `url_template` points at the release archive — no bottle is built or hosted; the version claim is true, only the term is wrong. Revert: delete the two scripts and reopen DOC-05/06/07/09/10/11 as live findings. | diff --git a/docs/planning/meta-plan.md b/docs/planning/meta-plan.md index cecf44f..1036d2c 100644 --- a/docs/planning/meta-plan.md +++ b/docs/planning/meta-plan.md @@ -75,19 +75,31 @@ minimal. ## Phase 5 — Epic execution -Spec-first, vertical slices per epic (proposed cut, refined in Phase 3): - -| Epic | Slice | -| --- | --- | -| E1 | Canonical change model: JSON + YAML (+ HCL/tfvars) | -| E2 | Decision engine + Rego frontend | -| E3 | Declarative YAML frontend | -| E4 | Forge: GitLab adapter (threads, approve, merge) | -| E5 | Provider host: built-ins + HTTP/exec | -| E6 | Adopter test harness (`assent test`) + examples | -| E7 | E2E infra: kind GitLab, sample-repo generator, conformance suite | -| E8 | Forge: GitHub adapter + Actions entrypoint | -| E9 | Distribution: releases, container, CI templates, docs site | +Spec-first, vertical slices per epic. The E-numbering below is the one that actually +executed — each row names its spec under `openspec/specs/` — and it matches the README +feature-maturity table. (The Phase-2 proposed cut had E2 as a Rego frontend and E8 as the +GitHub adapter; both moved to the deferred tier, so the numbering shifted.) + +| Epic | Slice | Spec | Status | +| --- | --- | --- | --- | +| E1 | Canonical change model: JSON + YAML (+ HCL/tfvars) | `p5-e1-canonical-change-model` | shipped | +| E2 | Decision engine + CEL predicate backend | `p5-e2-decision-engine` | shipped | +| E3 | Policy surface: `assent lint` hard errors + rule catalogue | `p5-e3-policy-surface` | shipped | +| E4 | GitLab forge adapter: Snapshot / Resolve / Reconcile | `p5-e4-gitlab-forge` | shipped | +| E5 | Provider host + builtins (HTTP/exec, gitlab-groups, ownership) | `p5-e5-provider-host` | shipped | +| E6 | Adopter test harness (`assent test`) + `assent compare` seed | `p5-e6-adopter-test` | shipped | +| E7 | E2E & conformance infra | `p5-e7-e2e-conformance` | shipped | +| E8 | Renderer & presentation (ADR-0016 tier 0) | `p5-e8-renderer` | shipped | +| E9 | Distribution & release (oss-playbook) | `p5-e9-distribution` | shipped (v0.1.0) | + +Follow-on epics cut during Phase 5, outside the E1–E9 sequence: **EFE** +(`p5-e-fileevents`, whole-file `match.fileEvents`), **PCS** +(`p5-pcs-policy-comparison`, full comparison-suite runner), **AUD** +(`p5-aud-audit-remediation`, post-release audit remediation). + +Deferred tiers keep their own numbers and unlock only with a named consumer (D-012): +**E10** GitHub adapter, **E11** Rego backend, **E12** `serve` (HTTP API), **E13** remote +packs — see the feature-maturity table in `README.md`. Ordering constraint: E7 starts early (alongside E1) because every later epic's exit gate depends on it. diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 601e82c..e9d54d8 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -85,7 +85,7 @@ and is never a flag; without it the command exits `2` before contacting the forg | `-binding` | `.assent/ruleset-binding.yaml` | RulesetBinding path, loaded from the target ref | | `-config` | — | optional Config path; when set, provider posture is validated | | `-pack` | — | optional Pack path; its `spec.phase` caps every rule's phase | -| `-checkout` | — | local checkout dir (`base/` + `head/` subtrees) used to enumerate the MR's full changed-file set; when unset, only the governed subject is diffed | +| `-checkout` | — | local checkout dir (`base/` + `head/` subtrees) used to enumerate the MR's full changed-file set; when unset, the forge snapshot is the sole enumerator (see below) | | `-emit` | stdout | path to write the `DecisionRecord` JSON | | `-arm` | off | sandbox arming override — approve and merge only when set **and** the decision is APPROVE | @@ -93,6 +93,22 @@ Exit codes: `0` the run completed and produced a valid receipt (an advisory REVIEW/BLOCK, or an APPROVE without `--arm`, is still a clean `0`); `1` a hard error during orchestration; `2` a missing flag, a missing `GITLAB_TOKEN`, or `-h`. +### Checkout-less runs and enumeration completeness + +Without `-checkout`, the forge snapshot's changed-file list is the only thing that can +see a `.assent/**` policy edit outside the governed subject — so an incomplete list +would silently starve the self-edit guard. Per [ADR-0020](../adr/0020-forge-snapshot-changed-file-completeness.md) +the adapter must therefore *prove* completeness (paginated `/diffs`, cross-checked +against the MR's `changes_count`, below a page ceiling). When it cannot, the run does +not guess and does not fail silently: the change set is marked opaque and the decision +degrades to **REVIEW** with finding code `changeset.undecidable`, carrying the gap +reason. A `DecisionRecord` is still emitted and a thread still posted; approve and +merge are impossible on that path. A `.assent/**` path that *is* visible in a partial +list still dominates to BLOCK. + +With `-checkout` the local tree is the sole authority (D-077) and snapshot completeness +is not consulted. + ## assent doctor Report whether this environment can arm auto-merge, and why not when it cannot. diff --git a/docs/usage/install.md b/docs/usage/install.md index 8b2f551..ec5e3b9 100644 --- a/docs/usage/install.md +++ b/docs/usage/install.md @@ -3,6 +3,11 @@ Assent ships as a single static Go binary. Prefer a checksum-verified install for release artifacts; use `go install` when developing from source. +Only the **release archives and the Homebrew bottle carry a stamped version** — +goreleaser injects it at link time (`-s -w -X main.version={{.Version}}` in +`.goreleaser.yaml`). A `go install` build has no such injection; see the caveat below +before you rely on `assent version` for provenance. + ## go install Requires Go 1.25+ (see `go.mod`). @@ -23,6 +28,24 @@ Confirm: assent version ``` +!!! warning "`go install` binaries report `0.0.0-dev`" + + `go install` does not pass the release ldflags, so the version stays at its + compile-time default whatever ref you build: + + ```console + $ go install github.com/PlatformRelay/assent/cmd/assent@v0.1.0 + $ assent version + assent 0.0.0-dev + ``` + + That is cosmetic for local policy authoring (`assent lint` / `assent test`). A + `DecisionRecord` from such a binary is still identifiable — `pins.toolDigest` is a + sha256 over the binary's Go build info (D-120), so different builds differ regardless + of the version string — but `pins.toolVersion` reads `0.0.0-dev` and cannot be mapped + back to a released tag. Use the [release archive](#github-release-url-pattern) or + [Homebrew](#homebrew) route when the version string itself has to be true. + ## curl / local install script (checksum-verified) [`hack/install.sh`](https://github.com/PlatformRelay/assent/blob/main/hack/install.sh) verifies the archive SHA256 against a @@ -46,7 +69,7 @@ Pick the archive that matches your OS/arch if the glob expands to more than one ### GitHub release URL pattern -Once tagged releases publish (E9-S05/S06): +Tagged releases publish under this pattern (`v0.1.0` onwards): ```bash VERSION=0.1.0 diff --git a/docs/usage/walkthrough.md b/docs/usage/walkthrough.md index dc3480a..9dd5cf7 100644 --- a/docs/usage/walkthrough.md +++ b/docs/usage/walkthrough.md @@ -1,8 +1,11 @@ -# Imagined walkthrough — adopting assent on a topic registry - -> **This is design fiction.** Nothing below is implemented; it exists so we can *feel* the -> UX before freezing contracts (meta-plan Phase 3). Where a command or field survives review, -> it becomes a spec REQ. Config semantics: [ADR-0010](../adr/0010-config-files-repo-layout.md); +# Walkthrough — adopting assent on a topic registry + +> **Mixed status — read the per-step banner.** This page began life as a UX sketch +> written before any code existed. Most of it now describes the shipped v0.1.0 binary; the steps +> that still describe unbuilt commands are labelled **Planned**, and each one names what +> you can do today instead. The authority for what exists is +> [the CLI reference](cli.md), which is pinned byte-for-byte to `assent --help`. +> Config semantics: [ADR-0010](../adr/0010-config-files-repo-layout.md); > effects: [ADR-0007](../adr/0007-rule-effects-decision-aggregation.md). ## The repo @@ -19,78 +22,123 @@ retentionMs: 604800000 Today every MR waits for a platform engineer. Goal: routine changes merge themselves. -## Step 1 — init (5 min) +## Step 1 — scaffold a policy tree -```console -$ assent init --sample topic-registry -created .assent/config.yaml (environments: prod/dev by path; class: kafka-topic) -created .assent/bindings.yaml (kafka-topic -> pack "topics", thresholds dev=10 prod=4) -created .assent/packs/topics/ (starter rules: ownership, bounded-change, no-deletion) -created .assent/tests/topics/ (passing fixtures for every starter rule) -next: assent test && assent scan --since 90d +> **Planned — `assent init` does not exist.** There is no scaffolding subcommand in the +> shipped binary. Copy a starter pack instead; the shipped ones are complete, linted, and +> covered by their own tests. + +```bash +git clone https://github.com/PlatformRelay/assent +cp -R assent/examples/packs/topic-registry/.assent .assent +assent lint . ``` -Committed starter pack (runnable policy content): [`examples/packs/topic-registry/`](https://github.com/PlatformRelay/assent/tree/main/examples/packs/topic-registry). +That gives you `.assent/config.yaml` (environments prod/dev by path, class +`kafka-topic`), `.assent/bindings.yaml`, `.assent/packs/topics/` (ownership, +bounded-change, non-destructive, schema-valid) and `.assent/tests/topics/` with a passing +fixture for every rule — `assent lint` rejects a rule with no test case, so the two ship +together by construction. + +Committed starter packs: [`examples/packs/`](https://github.com/PlatformRelay/assent/tree/main/examples/packs) +(`topic-registry`, `service-catalog`, `infra-vars`). ## Step 2 — make a rule yours -Edit the starter pack (`.assent/packs/topics/rules/safety.yaml`), e.g. cap partitions via -your quota provider and challenge retention shrinks — see the full rule file example in -ADR-0010. Wire your company's permission source in `config.yaml`: +> **Shipped.** Policy authoring is the frozen `assent.dev/v1alpha1` surface; `assent lint` +> is the gate over it. + +Edit the starter pack (`.assent/packs/topics/rules/bounded-change.yaml`), e.g. cap +partitions via your quota provider and challenge retention shrinks — see the full rule +file example in ADR-0010. Wire your company's permission source in `config.yaml`: ```yaml providers: - author: { type: builtin/gitlab-groups } # swap for http/exec/grpc — ADR-0004 + author: { type: builtin/gitlab-groups, failure: closed } # http/exec too — ADR-0004 ``` +Re-run `assent lint .` after every edit: unknown fields, unknown enums, duplicate +collection IDs, a rule without a test, and an unsupported `match.fileEvents` kind are all +hard errors, not warnings. + ## Step 3 — test the policy like code +> **Shipped** — `assent test ` runs `.assent/tests/**` against the real engine. + ```console -$ assent test -PACK topics - ✓ partition-increase-ok APPROVE (2 obligations proved, score 1/10) - ✓ partition-decrease-challenged REVIEW: challenge retention-shrink-challenge - ✓ topic-delete-blocked BLOCK no-topic-deletion - ✗ foreign-topic-edit expected REVIEW, got APPROVE - ownership: facts.author.groups fixture lists team-orders; entry owner is team-billing - -> did you mean expect: REVIEW (uncovered)? see .assent/tests/topics/foreign-topic-edit/ -4 fixtures, 3 passed, 1 failed +$ assent test . +PASS topics/bounded-change (APPROVE) +PASS topics/bounded-change/negative (REVIEW) +PASS topics/ownership (APPROVE) +PASS topics/ownership/negative (REVIEW) +PASS topics/schema-valid (APPROVE) +PASS topics/schema-valid/negative (BLOCK) +PASS topics/non-destructive (APPROVE) +PASS topics/non-destructive-delete (REVIEW) ``` +That is real output from the shipped starter pack. Each case is a directory with +`base/`, `head/`, `facts.yaml` and `expect.yaml` (or an inline `cases.yaml` entry); a +failing case prints the expected and actual decision plus the findings that differ. +`--update` rewrites expectations from the produced actuals (refused when `CI` is set), +`--coverage` is the read-only both-polarity completeness gate. Exit `0` every case +matched; `1` a mismatch, write or load error; `2` usage, discovery, or the CI guard +refusing `--update`. + ## Step 4 — backtest before trusting it -```console -$ assent scan --since 90d --out reports/ -scanned 214 MRs (2026-04-22..2026-07-21) -$ assent stats reports/ -outcome count % top rules firing -APPROVE 131 61% bounded-change/partition-increase (88) -REVIEW 71 33% retention-shrink-challenge (24), uncovered-change (31) -BLOCK 12 6% no-topic-deletion (12) -would-have-automerged: 61% · median score 2 · 0 nondeterministic re-runs -⚠ facts resolved live (today), not as of each MR — treat the % as an estimate +> **Planned — `assent scan` and `assent stats` do not exist.** There is no historical +> backtest over closed MRs in v0.1.0, and no "would-have-automerged %" report. + +What ships instead is **`assent compare`**: replay a comparison suite of a *baseline* +versus a *candidate* policy over a fixed corpus and apply the promotion gates, so you can +see what a policy change would do before you enforce it. + +```bash +assent compare --suite compare-suite/ ``` -61% automerge on day one, and the 12 blocks are all real topic deletions. Ship it. +Use it the way you would use a backtest for *policy edits*. For first-adoption evidence +on live MRs, set the pack's rollout `phase: observe` (ADR-0018) — rules evaluate and land +in `findings.observed`, structurally excluded from the decision — and read the emitted +`DecisionRecord`s rather than expecting a scan report. ## Step 5 — wire CI (GitLab first) +> **Shipped** — `assent run` is the CI entry point. One caveat below: **no container +> image is published**, so the job installs the binary. + ```yaml # included from a PROTECTED source (compliance pipeline / protected include) — the assent # job definition must not be editable from the MR branch (ADR-0015 §4); `assent doctor` # verifies this and the required forge settings (all-threads-resolved merge gate) at setup. # One-publisher-per-MR (ADR-0019 §3): resource_group keyed per MR IID serializes concurrent # assent jobs for the same MR. Without it, duplicates converge only on the next reconcile. -# (serve mode: use a keyed per-MR lock instead — multi-replica HA is unsupported.) assent: - image: ghcr.io//assent:v0 + image: golang:1.25 # no ghcr.io/…/assent image is published yet — install in-job rules: [{ if: $CI_MERGE_REQUEST_IID }] resource_group: assent-mr-$CI_MERGE_REQUEST_IID - script: [assent run] # MR context from CI env; least-privilege token from CI variable + before_script: + # Simplest route, shown here to keep the example self-contained. It stamps the + # binary `0.0.0-dev`, so every DecisionRecord this job emits carries that in + # `pins.toolVersion` (`pins.toolDigest` still identifies the build, D-120). + # For a record that names the real tag, install the checksum-verified release + # archive instead — the full URL pattern is in the install guide. + - go install github.com/PlatformRelay/assent/cmd/assent@v0.1.0 + script: + - assent run --project "$CI_PROJECT_ID" --mr "$CI_MERGE_REQUEST_IID" + --subject "file:$SUBJECT" --bot-author "$ASSENT_BOT" ``` +`GITLAB_TOKEN` comes from a masked CI variable and is never a flag. Run `assent doctor` +once during setup: it reports whether this environment can arm auto-merge and why not +when it cannot. See [Install](install.md) for the checksum-verified archive route, and +[CLI reference](cli.md) for every `assent run` flag. + ## Step 6 — the contributor experience +> **Shipped**, except the `assent explain` block at the end of this section. + A dev bumps `partitions: 12 -> 24` on their own topic in dev: pipeline runs, the MR gets a summary comment ("APPROVE — 1 obligation proved, score 1/10"), approval, and merges. Nobody was interrupted. @@ -104,7 +152,10 @@ resolved, **GitLab itself** merges (ADR-0009 amendment). Any new push cancels th re-evaluates from scratch; the policies that judged this MR came from the *target* branch, so nobody can weaken the rules in the MR they gate (ADR-0015). -Something weird? Anyone can ask locally: +> **Planned — `assent explain` does not exist.** Today the same information is in the +> `DecisionRecord` that every run emits (`assent run --emit record.json`): matched and +> unmatched rules, obligation results, and the aggregation that produced the decision. +> The sketch below is the intended local ergonomics, not a shipped command. ```console $ assent explain --mr 481 @@ -118,8 +169,16 @@ change topics/prod/orders.yaml /retentionMs modify 604800000 -> 86400000 aggregation: no block · 1 unresolved challenge -> REVIEW ``` -## What this walkthrough commits us to +## Status summary + +| Step | Command | Status | +| --- | --- | --- | +| 1 — scaffold | `assent init` | **Planned** — copy `examples/packs//.assent` | +| 2 — author | `assent lint ` | **Shipped** | +| 3 — test | `assent test ` | **Shipped** | +| 4 — backtest | `assent scan` / `assent stats` | **Planned** — `assent compare` covers policy-change replay | +| 5 — CI | `assent run` · `assent doctor` | **Shipped** (no published container image) | +| 6 — explain | `assent explain` | **Planned** — read the emitted `DecisionRecord` | -`init` with runnable samples · fixture tests with decision-level asserts and helpful failure -hints · `scan`/`stats` for evidence-based rollout · one-line CI install · rendered comments -with expandable docs/debug · `explain` that answers "why" without reading Go code. +The full dispatched command set — including `catalogue`, `render`, `eval-input` and +`version` — is in the [CLI reference](cli.md). diff --git a/examples/README.md b/examples/README.md index 2107df1..0f61604 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,13 +2,26 @@ Living documentation. Rules are written in the **YAML envelope** (ADR-0002 v2/ADR-0013): `assert` predicates (CEL leaves, string shorthand) for the archetypes, `rego` modules where -logic outgrows tier 1 — the rego example here shows that escape hatch. Every example must -pass the adopter test harness (`assent test`) once it exists — examples that don't run are -lies. +logic outgrows tier 1 — the rego example here sketches that escape hatch. Examples that +don't run are lies, so the runnable ones are executed by the gates, not just read. -> ⚠️ Pre-alpha: schemas are **illustrative drafts** anchored by ADR-0010/0013; the -> authoritative contracts are frozen in meta-plan Phase 3. +- [`packs/`](packs/) — complete adopter policy trees (`topic-registry`, `service-catalog`, + `infra-vars`). Each is a repo root: `assent lint ` is clean and + `assent test ` passes, both under `task check`. Start here. +- [`policies/declarative/`](policies/declarative/) — standalone envelope rules with + `assert` predicates +- [`policies/rego/`](policies/rego/) — the tier-2 escape hatch for the same archetype. + **Illustrative only**: the Rego backend is a deferred tier (E11); the CEL/assert path is + what ships. +- [`archetypes/`](archetypes/) — one directory per rule archetype from `docs/vision.md` +- [`lint-fixtures/`](lint-fixtures/) — `good`/`bad` pairs pinning each `assent lint` + hard error in both polarities +- [`repos/`](repos/) — generic sample self-service repo layouts (generated; e2e seeds) and + the open-source corpus snapshots +- [`comparison/`](comparison/) — `assent compare` suites and promotion-gate fixtures +- [`render/`](render/) — committed finding fixtures for `assent render` +- [`contracts/`](contracts/) — frozen contract fixtures (D-016 strict, named-consumer compat) -- [`policies/declarative/`](policies/declarative/) — envelope rules with `assert` predicates -- [`policies/rego/`](policies/rego/) — the tier-2 escape hatch for the same archetype -- [`repos/`](repos/) — generic sample self-service repo layouts (generated; e2e seeds) +The authored surfaces here are the **frozen** `assent.dev/v1alpha1` schemas under +`schemas/`, not drafts; the compatibility promises attached to them are in +[`API_STABILITY.md`](../API_STABILITY.md). diff --git a/examples/packs/infra-vars/.assent/config.yaml b/examples/packs/infra-vars/.assent/config.yaml index cb77088..72a4c10 100644 --- a/examples/packs/infra-vars/.assent/config.yaml +++ b/examples/packs/infra-vars/.assent/config.yaml @@ -1,4 +1,5 @@ -# Starter pack for examples/repos/infra-vars (assent init --sample infra-vars). +# Starter pack for examples/repos/infra-vars. Copy this .assent/ tree into your +# repo root (there is no `assent init` subcommand — see docs/usage/walkthrough.md). # # Opaque-change fallback (ADR-0003): when the HCL parser cannot classify a construct # (parse failure or unmodeled shape), the change is opaque → REVIEW — never silently diff --git a/examples/packs/service-catalog/.assent/config.yaml b/examples/packs/service-catalog/.assent/config.yaml index 7dc948b..89a9e77 100644 --- a/examples/packs/service-catalog/.assent/config.yaml +++ b/examples/packs/service-catalog/.assent/config.yaml @@ -1,4 +1,5 @@ -# Starter pack for examples/repos/service-catalog (assent init --sample service-catalog). +# Starter pack for examples/repos/service-catalog. Copy this .assent/ tree into your +# repo root (there is no `assent init` subcommand — see docs/usage/walkthrough.md). # Keyed list: packs/catalog declares entries.mode=list with identity.pointer=/name # (never unkeyed — ADR-0017 §5). Reordering entries must not change EntryRef subjects. apiVersion: assent.dev/v1alpha1 diff --git a/examples/packs/topic-registry/.assent/config.yaml b/examples/packs/topic-registry/.assent/config.yaml index 2644a7d..76c2eed 100644 --- a/examples/packs/topic-registry/.assent/config.yaml +++ b/examples/packs/topic-registry/.assent/config.yaml @@ -1,4 +1,5 @@ -# Starter pack for examples/repos/topic-registry (assent init --sample topic-registry). +# Starter pack for examples/repos/topic-registry. Copy this .assent/ tree into your +# repo root (there is no `assent init` subcommand — see docs/usage/walkthrough.md). # Class kafka-topic: one YAML file per topic; map-at-root identity = the topic-name key # (EFE-S04: document mode leaves Entry nil under whole-pack replay — D-056 — so # entry.owner cannot bind; map mode reconstructs the topic object for ownership). diff --git a/hack/docs/readme_smoke_test.sh b/hack/docs/readme_smoke_test.sh new file mode 100755 index 0000000..f3dd01f --- /dev/null +++ b/hack/docs/readme_smoke_test.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# REQ-AUD-S06-01 (DOC-07) — the README quick-start is EXECUTED, not eyeballed. +# +# Extracts every fenced ```bash block from README.md's "## Quick start" section and +# runs each `assent …` line verbatim against a freshly built binary, in a throwaway +# copy of the sample repo the README itself names. A quick-start command that exits +# non-zero fails this gate — that is the whole point: `assent lint .assent/` shipped +# in the README for the entire pre-release period and exits 2 for every reader. +# +# Two command families are DELIBERATELY skipped, loudly (never silently dropped): +# go install … — needs the network and the module proxy; and what it produces is +# pinned separately by the DOC-11 caveat pin in truthlag_pins_test.sh. +# task … — this script is INTENDED to run inside `task check` (D-124: wiring is +# Lane B's, not yet landed), so invoking `task check` from here would +# recurse the moment that wiring exists. +# Every skip is printed with its reason and counted, so deleting the executed lines +# cannot leave the script trivially green (see the "no assent command" fail below). +# +# NOT YET WIRED INTO ANY GATE (D-124): `Taskfile.yml` is Lane B's file, so today this +# runs only when invoked by hand. Until Lane B adds it (and truthlag_pins_test.sh) to +# `task check`, a README edit can reopen DOC-07 with nothing going red. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +BIN="$WORK/bin/assent" +mkdir -p "$WORK/bin" +echo "building $BIN" +go build -o "$BIN" ./cmd/assent + +# The fixture is whatever the README names — single source of truth, so retitling the +# sample repo in the README cannot leave this script testing a stale path. +FIXTURE_REL="$(grep -o 'examples/packs/[a-z0-9-]*' README.md | head -1 || true)" +if [[ -z "$FIXTURE_REL" ]]; then + echo "FAIL: README.md quick-start names no examples/packs/ fixture to run against" >&2 + exit 1 +fi +if [[ ! -d "$ROOT/$FIXTURE_REL/.assent" ]]; then + echo "FAIL: README names fixture $FIXTURE_REL, which has no .assent/ tree" >&2 + exit 1 +fi +FIXTURE="$WORK/repo" +mkdir -p "$FIXTURE" +cp -R "$ROOT/$FIXTURE_REL/." "$FIXTURE/" +echo "fixture: $FIXTURE_REL -> $FIXTURE" + +# Quick-start bash blocks: from "## Quick start" to the next "## " heading, keeping +# only the contents of ```bash fences. +BLOCKS="$WORK/quickstart.sh" +awk ' + /^## Quick start/ { inqs = 1; next } + inqs && /^## / { inqs = 0 } + inqs && /^```bash/ { infence = 1; next } + inqs && infence && /^```/ { infence = 0; next } + inqs && infence { print } +' README.md > "$BLOCKS" + +if [[ ! -s "$BLOCKS" ]]; then + echo "FAIL: no \`\`\`bash blocks found under README.md '## Quick start'" >&2 + exit 1 +fi + +ran=0 +skipped=0 +failed=0 +while IFS= read -r line; do + # Strip trailing inline comments (`task check # fmt + vet …`) and whitespace. + cmd="${line%%#*}" + cmd="$(printf '%s' "$cmd" | sed -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')" + [[ -z "$cmd" ]] && continue + + case "$cmd" in + "go install"*) + echo "SKIP $cmd (network + module proxy; its output is pinned by the DOC-11 caveat pin)" + skipped=$((skipped + 1)) + continue + ;; + task*) + echo "SKIP $cmd (this script belongs inside \`task check\`; invoking it here would recurse once wired — D-124)" + skipped=$((skipped + 1)) + continue + ;; + assent*) + run="$BIN${cmd#assent}" + ;; + *) + echo "FAIL: quick-start line is neither an assent command nor a known skip: $cmd" >&2 + failed=$((failed + 1)) + continue + ;; + esac + + echo "RUN ($FIXTURE_REL) $cmd" + if (cd "$FIXTURE" && eval "$run"); then + ran=$((ran + 1)) + else + rc=$? + echo "FAIL: README quick-start command exited $rc: $cmd" >&2 + failed=$((failed + 1)) + fi +done < "$BLOCKS" + +if [[ "$ran" -eq 0 ]]; then + echo "FAIL: no assent command in the README quick-start was executed ($skipped skipped) — the gate would be vacuous" >&2 + exit 1 +fi +if [[ "$failed" -ne 0 ]]; then + echo "FAIL: $failed README quick-start command(s) failed" >&2 + exit 1 +fi + +echo "OK: $ran README quick-start command(s) green, $skipped skipped with a stated reason" diff --git a/hack/docs/truthlag_pins_test.sh b/hack/docs/truthlag_pins_test.sh new file mode 100755 index 0000000..b6eed7e --- /dev/null +++ b/hack/docs/truthlag_pins_test.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# REQ-AUD-S06-02 — grep pins for the truth-lag DOC-05/06/09/10/11 closed. +# +# These are cheap mechanisms that keep a corrected claim corrected. They cover the +# surfaces `cmd/assent`'s TestNoStaleProductClaims does NOT walk (repo-root markdown +# and examples/), plus four drift pairs that no build step checks: +# +# DOC-05 README.md's relative links resolve on the filesystem. `mkdocs --strict` +# cannot see them: README.md is outside docs_dir. +# DOC-06 neither API-stability copy claims fileEvents is unimplemented, and the +# published mirror stays byte-identical to the root file modulo the +# docs-relative link prefixes. +# DOC-09 the walkthrough carries no design-fiction banner, and every step carries a +# Shipped/Planned banner. +# DOC-10 the meta-plan Phase-5 epic table covers E1..E9 and does not bind a +# deferred-tier concept (Rego, GitHub adapter) to one of those numbers. +# DOC-11 the `go install` caveat names the version it actually prints. +# plus docs/adr/README.md's status column agrees with each ADR's own Status row. +# +# Every check prints PASS or FAIL and the script exits 1 if any failed, so a +# regression names the finding it reopens. +# +# NOT YET WIRED INTO ANY GATE (D-124): `Taskfile.yml` is Lane B's file, so today this +# runs only when invoked by hand. Until Lane B adds it (and readme_smoke_test.sh) to +# `task check`, a docs edit can reopen these findings with nothing going red. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +fails=0 +pass() { echo "PASS $1"; } +fail() { echo "FAIL $1" >&2; fails=$((fails + 1)); } + +# --- retired pre-release phrases ------------------------------------------------- +# Scope: repo-root markdown + examples/**, i.e. exactly what TestNoStaleProductClaims +# leaves uncovered. README's maturity legend legitimately defines "Planned = designed +# seam, not yet implemented", so that phrase is pinned only on the contract files. +for f in README.md API_STABILITY.md examples/README.md; do + if grep -qi 'pre-alpha' "$f"; then + fail "DOC-08/examples: $f still carries a 'pre-alpha' claim" + else + pass "no pre-alpha claim in $f" + fi +done + +for f in API_STABILITY.md docs/api-stability.md; do + if grep -qi 'not yet implemented' "$f"; then + fail "DOC-06: $f still says something is 'not yet implemented' — fileEvents ships add/delete" + else + pass "DOC-06: no unimplemented claim in $f" + fi + if ! grep -q 'fileEvents' "$f"; then + fail "DOC-06: $f no longer discusses fileEvents at all — the pin above went vacuous" + else + pass "DOC-06: $f still carries the fileEvents match-domain note" + fi +done + +# --- DOC-06: the published mirror tracks the root file --------------------------- +MIRROR_EXPECTED="$(mktemp)" +trap 'rm -f "$MIRROR_EXPECTED"' EXIT +sed -e 's|](docs/adr/|](adr/|g' -e 's|](docs/planning/|](planning/|g' API_STABILITY.md > "$MIRROR_EXPECTED" +if diff -q "$MIRROR_EXPECTED" docs/api-stability.md >/dev/null; then + pass "DOC-06: docs/api-stability.md matches API_STABILITY.md (modulo docs-relative links)" +else + fail "DOC-06: docs/api-stability.md has drifted from API_STABILITY.md:" + diff "$MIRROR_EXPECTED" docs/api-stability.md >&2 || true +fi + +# --- DOC-05: README relative links resolve --------------------------------------- +broken=0 +checked=0 +while read -r target; do + [[ -z "$target" ]] && continue + checked=$((checked + 1)) + # Strip any #anchor; only the file must exist. + path="${target%%#*}" + [[ -z "$path" ]] && continue + if [[ ! -e "$path" ]]; then + echo " broken README link: $target" >&2 + broken=$((broken + 1)) + fi +done < <(grep -o '](\(docs\|examples\|hack\|internal\|cmd\|schemas\|openspec\|test\)/[^)]*)' README.md | sed -e 's/^](//' -e 's/)$//') + +if [[ "$checked" -eq 0 ]]; then + fail "DOC-05: README.md has no in-repo relative links to check — the pin would be vacuous" +elif [[ "$broken" -ne 0 ]]; then + fail "DOC-05: $broken README.md link(s) do not resolve on the filesystem" +else + pass "DOC-05: all $checked in-repo README.md links resolve" +fi + +# --- DOC-09: walkthrough banners -------------------------------------------------- +WT=docs/usage/walkthrough.md +if grep -qi 'design fiction\|Nothing below is implemented' "$WT"; then + fail "DOC-09: $WT still opens with a design-fiction banner" +else + pass "DOC-09: no design-fiction banner in $WT" +fi + +# Per-step, not a count: each `## Step` heading must be FOLLOWED by its own +# Shipped/Planned banner before the next heading. A count comparison is not +# discriminating — extra banners elsewhere on the page would mask a step that lost one. +unbannered="$(awk ' + /^## / { + if (step != "" && !seen) print step + step = "" + if ($0 ~ /^## Step /) { step = $0; seen = 0 } + next + } + step != "" && /^> \*\*(Shipped|Planned)/ { seen = 1 } + END { if (step != "" && !seen) print step } +' "$WT")" +steps="$(grep -c '^## Step ' "$WT")" +if [[ "$steps" -eq 0 ]]; then + fail "DOC-09: $WT has no '## Step' sections — the banner pin would be vacuous" +elif [[ -n "$unbannered" ]]; then + fail "DOC-09: walkthrough step(s) with no Shipped/Planned banner:" + printf ' %s\n' "$unbannered" >&2 +else + pass "DOC-09: all $steps walkthrough steps carry a Shipped/Planned banner" +fi + +# --- DOC-10: meta-plan epic numbering --------------------------------------------- +MP=docs/planning/meta-plan.md +# The Phase-5 epic table rows, E1..E9. +epic_rows="$(grep -c '^| E[1-9] |' "$MP")" +if [[ "$epic_rows" -ne 9 ]]; then + fail "DOC-10: $MP Phase-5 table has $epic_rows E1..E9 rows, expected 9" +else + pass "DOC-10: $MP lists all nine executed epics" +fi +# Deferred tiers must not be bound to an E1..E9 row (the old table had E2=Rego, E8=GitHub). +for concept in Rego GitHub serve; do + if grep '^| E[1-9] |' "$MP" | grep -qi "$concept"; then + fail "DOC-10: $MP binds deferred tier '$concept' to an E1..E9 row — README says E10-E13" + else + pass "DOC-10: deferred tier '$concept' is not an E1..E9 row in $MP" + fi +done + +# --- DOC-11: the go install version caveat ---------------------------------------- +for f in README.md docs/usage/install.md; do + if grep -q '0\.0\.0-dev' "$f" && grep -q 'go install' "$f"; then + pass "DOC-11: $f states what \`go install\` actually reports" + else + fail "DOC-11: $f mentions \`go install\` without the 0.0.0-dev caveat" + fi +done + +# --- ADR index status agrees with each ADR's own Status row ------------------------ +adr_checked=0 +for adr in docs/adr/0*.md; do + base="$(basename "$adr")" + own="$(grep -m1 '^| \*\*Status\*\* |' "$adr" | sed -e 's/^| \*\*Status\*\* | *//' -e 's/ *|$//' | awk '{print $1}')" + # Appendices (e.g. 0013-appendix-syntax-gallery.md) carry no Status row of their own + # and get no index row of their own; they are linked from their parent ADR's row. + [[ -z "$own" ]] && continue + # Only the row whose FIRST cell links this file — not a parent ADR row that mentions it. + row="$(grep "^| \[[0-9]*\]($base)" docs/adr/README.md | head -1)" + [[ -z "$row" ]] && { fail "ADR index: no row for $base"; continue; } + idx="$(printf '%s' "$row" | awk -F'|' '{print $4}' | sed -e 's/^ *//' | awk '{print $1}')" + adr_checked=$((adr_checked + 1)) + if [[ "$own" != "$idx" ]]; then + fail "ADR index: $base says '$own' but docs/adr/README.md says '$idx'" + fi +done +if [[ "$adr_checked" -eq 0 ]]; then + fail "ADR index: no ADRs compared — the pin would be vacuous" +else + pass "ADR index: $adr_checked ADR status rows agree with their files" +fi + +if [[ "$fails" -ne 0 ]]; then + echo "FAILED: $fails truth-lag pin(s) reopened" >&2 + exit 1 +fi +echo "OK: all truth-lag pins green" diff --git a/hack/release/README.md b/hack/release/README.md index 027f8c9..3a828e0 100644 --- a/hack/release/README.md +++ b/hack/release/README.md @@ -48,6 +48,12 @@ notes — same pattern as mkurator: `hack/release/snapshot_test.sh` — both run goreleaser without publish credentials (sign/sbom skipped — no fake signatures locally). +**Never replace published assets in place (SEC-07).** Once a tag's artifacts are uploaded, +fix anything wrong with them by cutting a **patch tag** (`v0.1.1`), not by re-running the +workflow over the same tag: a consumer who verified the old checksum or attestation has no +way to learn the bytes changed underneath them. `workflow_dispatch` on an existing tag is for +recovering a *failed* upload, not for revising a successful one. + ### Supply chain on tagged release (E9-S06, D-109) The publish job in `.github/workflows/release.yaml` (tag push / `workflow_dispatch` only): diff --git a/internal/core/policy/policy.go b/internal/core/policy/policy.go index 123ec76..9e9c177 100644 --- a/internal/core/policy/policy.go +++ b/internal/core/policy/policy.go @@ -97,9 +97,10 @@ type RuleDocs struct { Summary string `yaml:"summary"` } -// Match is restricted to exactly one of four ADR-0017 §5 domains. FileEvents is -// modelled so an authored use is DETECTED and rejected at load — E1 deferred -// whole-file fileEvents, so E2 supports only files/values/valueChanges. +// Match is restricted to exactly one of four ADR-0017 §5 domains. All four are +// supported; FileEvents is narrowed at load to the kinds the engine can actually +// mint (add/delete — see LoadMergePolicy and fileEventKindEmittable), so an +// authored modify/rename is DETECTED and rejected rather than matching nothing. type Match struct { Files *FilesMatch `yaml:"files"` Values *ValuesMatch `yaml:"values"` @@ -118,7 +119,8 @@ type ValuesMatch struct { Pointers []string `yaml:"pointers"` } -// FileEventsMatch is the E1-deferred whole-file lifecycle domain (rejected). +// FileEventsMatch is the whole-file lifecycle domain (EFE). Kinds are narrowed at +// load to {add, delete}; modify and rename are rejected (no minting path exists). type FileEventsMatch struct { Paths []string `yaml:"paths"` Kinds []string `yaml:"kinds"`