Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ repository still gets a decision, never by following the link; no release carrie
- :memo: docs: fix ARM-08 advisory advice, annotate the dry-run drift, log OQ-29
- :memo: docs(usage): say that an advisory run still writes (ADV-01)
- :memo: docs(decisions): retire D-136's PR-guard premise instead of replacing it (CL-02/CL-03)
- :memo: docs(cli): record that --checkout is not bound to the evaluated commit (SEC-01)
- :memo: docs(adr): correct four ADR claims the code does not support
- :memo: docs: retract the unsafe advice at D-134's stopping point; dry-run is not shipped
- :memo: docs: record the reliability + security deferrals (D-138, D-139, OQ-31); OQ-29 to P1

### Features
- :sparkles: feat(cli): dispatch-table help listing the real subcommands (REQ-AUD-S05-01)
Expand All @@ -181,6 +185,8 @@ repository still gets a decision, never by following the link; no release carrie
- :bug: fix(engine): ordering a bytes operand is text ordering too — refuse it (D-131)
- :bug: fix(checkout): keep a rooted --subject working, and unshadow the path package
- :bug: fix(release): file gitmoji subjects by conventional type, not by emoji (REL-14, D-137)
- :bug: fix(docs): point every docs-site URL at the case-sensitive Pages path (DOC-02)
- :bug: fix(render): stop minting a 404 documentation link into every MR thread (DOC-03)

### Refactoring
- :recycle: fix(forge): retry idempotent GitLab reads with bounded jittered backoff (AUD-S11, REL-04)
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://platformrelay.github.io/assent/">
<a href="https://platformrelay.github.io/Assent/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/brand/assent-logo-light.svg">
<img src="docs/assets/brand/assent-logo.svg" alt="assent — deterministic, policy-driven auto-merge" width="420">
Expand All @@ -12,7 +12,7 @@
<a href="https://github.com/PlatformRelay/assent/actions/workflows/schemas.yml"><img src="https://github.com/PlatformRelay/assent/actions/workflows/schemas.yml/badge.svg" alt="Schemas"></a>
<a href="https://github.com/PlatformRelay/assent/actions/workflows/docs.yaml"><img src="https://github.com/PlatformRelay/assent/actions/workflows/docs.yaml/badge.svg" alt="Docs"></a>
<a href="https://securityscorecards.dev/viewer/?uri=github.com/PlatformRelay/assent"><img src="https://api.securityscorecards.dev/projects/github.com/PlatformRelay/assent/badge" alt="OpenSSF Scorecard"></a>
<a href="https://platformrelay.github.io/assent/"><img src="https://img.shields.io/badge/documentation-GitHub%20Pages-2ea44f?logo=materialformkdocs&logoColor=white" alt="Documentation"></a>
<a href="https://platformrelay.github.io/Assent/"><img src="https://img.shields.io/badge/documentation-GitHub%20Pages-2ea44f?logo=materialformkdocs&logoColor=white" alt="Documentation"></a>
<a href="https://github.com/PlatformRelay/assent/blob/main/LICENSE"><img src="https://img.shields.io/github/license/PlatformRelay/assent" alt="License: Apache-2.0"></a>
</p>

Expand All @@ -28,7 +28,7 @@ configuration repositories. Drop it into a repo's CI pipeline and it turns merge
into decisions: **approve, comment, request changes, or block** — based on rules *you*
write in **Kyverno-style declarative YAML** with CEL predicates.

**Read the docs:** **[platformrelay.github.io/assent](https://platformrelay.github.io/assent/)**
**Read the docs:** **[platformrelay.github.io/Assent](https://platformrelay.github.io/Assent/)**
— vision, architecture, ADRs, install guide, and usage walkthrough. This README is the
front door; the site is the map.

Expand Down Expand Up @@ -76,7 +76,7 @@ assent version
`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/).
routes are in [docs/usage/install.md](https://platformrelay.github.io/Assent/usage/install/).

Lint and test policies locally. Both commands take the **repository root** — `assent`
appends `.assent` itself, so passing `.assent/` makes it look for `.assent/.assent`:
Expand Down Expand Up @@ -116,7 +116,7 @@ Honest tiers post-E8 (D-104). **Core** = shipped and covered by conformance test

| Topic | Link |
| --- | --- |
| Documentation site | [platformrelay.github.io/assent](https://platformrelay.github.io/assent/) |
| Documentation site | [platformrelay.github.io/Assent](https://platformrelay.github.io/Assent/) |
| Install (go, curl, Homebrew) | [usage/install.md](docs/usage/install.md) |
| API & schema stability | [API_STABILITY.md](API_STABILITY.md) |
| Security policy & CI gates | [SECURITY.md](SECURITY.md) |
Expand Down
5 changes: 4 additions & 1 deletion cmd/assent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ func usageText() string {
_, _ = fmt.Fprintf(&b, " %s\n %s\n usage: %s\n", sc.name, sc.synopsis, sc.usage)
}
b.WriteString("\nassent run -h, assent compare -h and assent render -h list their flags.\n")
b.WriteString("Full command reference: https://platformrelay.github.io/assent/usage/cli/\n")
// The Pages path is case-sensitive and the repo is named `Assent` — the
// lowercase spelling 404s. Pinned against `mkdocs.yml`'s `site_url` by
// hack/docs/truthlag_pins_test.sh (DOC-02).
b.WriteString("Full command reference: https://platformrelay.github.io/Assent/usage/cli/\n")
return b.String()
}

Expand Down
22 changes: 18 additions & 4 deletions cmd/assent/run_render.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package main

import (
"github.com/PlatformRelay/assent/internal/catalogue"
"strings"

"github.com/PlatformRelay/assent/internal/change"
"github.com/PlatformRelay/assent/internal/core/aggregate"
"github.com/PlatformRelay/assent/internal/core/decision"
Expand Down Expand Up @@ -50,14 +51,27 @@ func rulesMetaFromPolicy(mp *policy.MergePolicy) map[string]render.RuleMeta {
if mp == nil {
return nil
}
pack := mp.Metadata.Name
out := make(map[string]render.RuleMeta, len(mp.Spec.Rules))
for _, r := range mp.Spec.Rules {
stableID := pack + "/" + r.Name
// DOC-03: do NOT mint `catalogue.DocsBase + "/" + pack + "/" + r.Name`
// here. That URL space does not exist on the docs site — measured, both
// `<site>/rules` and `<site>/rules/` 404 — so every finding posted into a
// contributor's MR thread carried a dead "Full documentation" link, on the
// one affordance a blocked contributor actually clicks. The run path now
// carries only the rule's AUTHORED docs.url; `renderDocsSection` omits the
// line entirely when it is empty, so no link beats a broken one.
//
// Today that fallback is DORMANT BY SCHEMA, not merely unused: the frozen
// v1alpha1 merge-policy schema is `additionalProperties: false` over
// [effect, match, message, name, onFailure, phase, points, prove], so an
// authored rule-level `docs:` is REJECTED at load and `policy.Rule.Docs`
// can never be populated from a conformant pack (audit ARCH-08). Adopters
// cannot supply a URL here yet — the honest present-day behaviour is
// therefore "no documentation link on the run path", which is the point.
out[r.Name] = render.RuleMeta{
Message: r.Message,
Docs: render.RuleDocs{
URL: catalogue.DocsBase + "/" + stableID,
URL: strings.TrimSpace(r.Docs.URL),
},
}
}
Expand Down
106 changes: 106 additions & 0 deletions cmd/assent/run_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"
"testing"

"github.com/PlatformRelay/assent/internal/catalogue"
"github.com/PlatformRelay/assent/internal/change"
"github.com/PlatformRelay/assent/internal/core/aggregate"
"github.com/PlatformRelay/assent/internal/core/decision"
Expand Down Expand Up @@ -167,3 +168,108 @@ func TestBuildDesiredSummaryUsesRenderer(t *testing.T) {
})
}
}

// DOC-03 (audit 2026-08-09): the run path must not MINT a documentation URL.
//
// `internal/catalogue.DocsBase + "/" + pack + "/" + rule` was attached to every
// finding on the run path and rendered as `📖 [Full documentation](…)` in the
// contributor's MR thread — but no `rules/` space exists on the docs site
// (measured: `<site>/rules` and `<site>/rules/` both 404), so the one affordance
// a blocked contributor clicks was dead on every finding of every MR.
//
// Both polarities, because "no link" is also what deleting the feature outright
// would produce and that must not pass as a fix:
//
// absent → no link line at all (and no docs-site URL anywhere in the body);
// authored → the AUTHORED url, verbatim, is the one that renders.
//
// The authored case is unreachable from a conformant pack TODAY: the frozen
// v1alpha1 merge-policy schema is `additionalProperties: false` over
// [effect, match, message, name, onFailure, phase, points, prove], so a rule-level
// `docs:` is rejected at load (audit ARCH-08). It is pinned here anyway so that
// closing ARCH-08 wires an authored URL through instead of re-minting a dead one.
func TestRunPathDocsLinkIsAuthoredNeverMinted(t *testing.T) {
for _, tc := range []struct {
name string
docsURL string
wantLink bool
wantInURL string
}{
{name: "absent_authored_url_renders_no_link", docsURL: "", wantLink: false},
{name: "authored_url_renders_verbatim", docsURL: "https://docs.example.test/rules/partitions", wantLink: true, wantInURL: "https://docs.example.test/rules/partitions"},
} {
t.Run(tc.name, func(t *testing.T) {
result := aggregate.Result{
Decision: aggregate.DecisionReview,
Findings: []aggregate.Finding{{
Rule: "partitions-must-not-shrink",
Obligation: "non-destructive",
Effect: aggregate.EffectChallenge,
Subject: "topic-registry:orders.events.v1",
Code: "partition-count-shrunk",
}},
}
pins := decision.Pins{
ToolVersion: "test",
ToolDigest: "sha256:abc",
PolicySha: "sha256:def",
SourceSha: "src",
TargetSha: "tgt",
MergeResult: decision.SkeletonMergeGap(),
}
report, err := decision.Build(result, pins)
if err != nil {
t.Fatalf("decision.Build: %v", err)
}
recordJSON, err := report.MarshalRecord()
if err != nil {
t.Fatalf("MarshalRecord: %v", err)
}

mp := &policy.MergePolicy{
Metadata: policy.Metadata{Name: "topic-safety"},
Spec: policy.MergePolicySpec{
Rules: []policy.Rule{{
Name: "partitions-must-not-shrink",
Message: "Partitions {{ old }} -> {{ new }}",
Docs: policy.RuleDocs{URL: tc.docsURL},
}},
},
}
bind := &policy.Binding{Environment: "prod"}
cs := change.ChangeSet{Changes: []change.Change{{
Path: "/partitions",
Kind: change.KindModify,
Old: "5",
New: "3",
}}}
rctx := buildRenderContext(render.DefaultOptions(), mp, bind, cs, nil, forge.MRInfo{}, "")

cfg := runConfig{project: "42", mr: "7", subject: "file:topics/orders.yaml"}
info := forge.MRInfo{SourceSHA: "src", TargetSHA: "tgt"}
desired, _ := buildDesired(cfg, info, cfg.subject, []byte("head-bytes"), result, recordJSON, false, report.Presentation, rctx)
if desired.Thread == nil {
t.Fatal("expected thread for REVIEW")
}
body := desired.Thread.Body

// The dead space must never reach a contributor. The needle is derived
// from catalogue.DocsBase rather than written out, so it tracks that
// constant instead of going stale beside it — and so this file holds no
// literal docs-site URL for the DOC-02 site_url pin to trip over.
for _, dead := range []string{catalogue.DocsBase, "github.io", "/rules/topic-safety"} {
if strings.Contains(body, dead) {
t.Errorf("run path minted a docs URL containing %q into the MR thread:\n%s", dead, body)
}
}

hasLink := strings.Contains(body, "Full documentation")
if hasLink != tc.wantLink {
t.Errorf("Full documentation link present = %v, want %v:\n%s", hasLink, tc.wantLink, body)
}
if tc.wantInURL != "" && !strings.Contains(body, tc.wantInURL) {
t.Errorf("authored docs.url %q did not reach the thread body:\n%s", tc.wantInURL, body)
}
})
}
}
2 changes: 1 addition & 1 deletion docs/adr/0002-policy-frontends-rego-declarative.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| | |
| --- | --- |
| **Status** | Accepted (v2 — supersedes the "two parallel frontends" draft of this ADR; P2-E5) |
| **Status** | Accepted (v2 — supersedes the "two parallel frontends" draft of this ADR; P2-E5). **The pluggable-backend half is UNBUILT as of 2026-08-09**: the YAML envelope and the CEL/`assert` backend are Core, but the **Rego backend is deferred to E11** and nothing selects a backend today — no `opa`/`rego` module dependency, no backend field in any frozen v1alpha1 policy schema. `README.md`'s maturity table (*Rego backend — Locked, E11*) and [`docs/architecture/c4-container.md`](../architecture/c4-container.md) (*PLANNED — E11*) are the accurate surfaces; this ADR is the design, not a statement of what ships. Do **not** cite Rego as an available escape hatch — see [ADR-0013](0013-assert-syntax-and-backend.md) Amendment 1 and D-012. |
| **Date** | 2026-07-21 (revised) |
| **Deciders** | Konrad Heimel |
| **Context links** | [ADR-0003 change model](0003-canonical-change-model.md) · [ADR-0007 effects](0007-rule-effects-decision-aggregation.md) · [ADR-0008 routing](0008-change-classification-routing-scope.md) · D-006 |
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0006-testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| | |
| --- | --- |
| **Status** | Accepted (CI e2e default: GitLab CE testcontainer — Spike B; P2-E5) |
| **Status** | Accepted (Spike B chose the GitLab CE testcontainer as the *intended* CI e2e default; P2-E5). **NOT IN EFFECT as of 2026-08-09 — no CI job boots one.** `test/e2e/**` is behind `//go:build e2e`, and `verify.yaml`'s only e2e touch is `go vet -tags e2e ./...`, which compiles the wiring and runs nothing; the L3 tier is armed by `ASSENT_E2E_GITLAB` against an operator-provided instance ([`test/e2e/README.md`](https://github.com/PlatformRelay/assent/blob/main/test/e2e/README.md)). Live-forge coverage is an **open assurance gap** (D-035/D-038), not a control this project currently runs. |
| **Date** | 2026-07-21 |
| **Deciders** | Konrad Heimel |
| **Context links** | [ADR-0005 forge](0005-forge-abstraction-gitlab-first.md) · [openspec/config.yaml](https://github.com/PlatformRelay/assent/blob/main/openspec/config.yaml) |
Expand Down
14 changes: 11 additions & 3 deletions docs/adr/0013-assert-syntax-and-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,17 @@ Consequences for authors:

- Ordering **quoted** numerics means coercing first: `int(new) >= int(old)` (already the idiom
in this repo's tests), or `double(...)`. Comparing ISO-8601 dates means `timestamp(a) < timestamp(b)`.
- **Ordering raw text is no longer expressible in tier-1** and graduates to the Rego escape
hatch (ADR-0002) — consistent with this ADR's design taste: don't grow a programming language
in YAML. There is no exempt spelling: `string(a) < string(b)` and the byte-wise
- **Ordering raw text is no longer expressible in tier-1**, and the supported answer is the
coercion above — `int(...)`, `double(...)`, `timestamp(...)` — which is also the honest one:
text that is *meant* to be ordered is almost always a number or a date wearing quotes, and
naming the type is what makes the comparison mean something. This is consistent with this
ADR's design taste: don't grow a programming language in YAML. **There is no escape hatch
today.** ADR-0002's pluggable Rego backend is **planned, not built** — E11 in the deferred
tier (D-012); there is no `opa`/`rego` module dependency, no `rego` property anywhere in the
frozen v1alpha1 policy schemas, and no backend selector a policy could set. A predicate that
genuinely needs ordering over opaque text has no in-product answer at present; express the
intent structurally (`match`, equality, membership) or model the field as the type it is.
There is no exempt spelling: `string(a) < string(b)` and the byte-wise
`bytes(a) < bytes(b)` are the same lexical sort as `a < b` and are refused with it. No policy
in the corpus, the comparison suite, or either dogfood pack ordered text.
- The refusal set is exactly CEL's two text-shaped types, `string` and `bytes`. Every other
Expand Down
19 changes: 18 additions & 1 deletion docs/adr/0019-publication-marker-reconciliation-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| | |
| --- | --- |
| **Status** | Accepted (D-030 — Phase-3 freeze review) |
| **Status** | Accepted (D-030 — Phase-3 freeze review). **One MUST is unmet:** `assent doctor` never emits `duplicate_prevention:` — see the *Implementation status* note below and [D-138](../decisions/decisions.md). |
| **Date** | 2026-07-24 |
| **Deciders** | Konrad Heimel |
| **Context links** | [ADR-0011](0011-core-ports-and-contracts.md) (`UpsertComment`/`SyncThreads`) · [ADR-0012](0012-presentation-templates-debug.md) (finding-key / marker comments) · [ADR-0015](0015-trust-boundaries-merge-integrity.md) §6 (serve dedup) · [ADR-0016](0016-presentation-theming.md) §1 (renderer-owned marker region) · [ADR-0017](0017-contract-model-obligations.md) §7 (`Reconcile`) · D-007 · D-017 (B6) · frozen contract [marker grammar](../contracts/p3-e5-publication-protocol/marker-grammar.md) |
Expand Down Expand Up @@ -90,6 +90,23 @@ claim `single-writer-serialized` when it cannot verify the serialization mechani
default on ambiguity is `unserialized-best-effort`. The setup walkthrough's CI step and the
doctor checklist must state this requirement explicitly (P3-E5-S04).

> **Implementation status (2026-08-09, [D-138](../decisions/decisions.md)) — the
> `duplicate_prevention:` MUST above is UNMET.** The value is computed and typed all the way to
> the report — `internal/forge/precondition.go` defines both constants and defaults to
> `unserialized-best-effort`, and `cmd/assent/doctor_forge.go:19` copies it into
> `PreconditionReport.DuplicatePrevention` — and then `emitDoctorReport` (same file, `:85-99`)
> prints only the arm-eligible verdict and the refusal reasons. **No `assent doctor` output
> contains the string `duplicate_prevention`.** The *safe-default* half of the MUST does hold:
> nothing can claim `single-writer-serialized` without the serialization mechanism, because
> `PreconditionFromCapabilities` seeds the field to `unserialized-best-effort`. What is missing
> is the emission, so an operator cannot read the guarantee level off the tool.
>
> Recorded rather than fixed, deliberately: this is one instance of the broader
> **audit ARCH-11** — doctor computes a typed capability report and prints essentially none of
> it, against the MUSTs of two ADRs. Emitting this one field would half-close ARCH-11 and leave
> the report inconsistent with itself, and it is a user-visible CLI output change; both belong
> in the v0.2.1 ARCH-11 slice with its own tests, not in a docs-truth lane.

## Consequences

- Phase-3 freeze review **accepted** this ADR (D-030); Status is **Accepted** and matches the
Expand Down
Loading
Loading