Skip to content

AUD-S15: lift MRInfo/ErrNotFound into the forge port (ARCH-02) - #34

Merged
konih merged 7 commits into
mainfrom
lane/aud-s15-forge-port-lift
Aug 8, 2026
Merged

AUD-S15: lift MRInfo/ErrNotFound into the forge port (ARCH-02)#34
konih merged 7 commits into
mainfrom
lane/aud-s15-forge-port-lift

Conversation

@konih

@konih konih commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

AUD-S15 — ARCH-02: lift MRInfo/ErrNotFound into the forge port (pre-E10).
Spec: openspec/specs/p5-aud-audit-remediation/spec.md (AUD-S15, REQ-AUD-S15-01/02).
Design note: docs/planning/design-notes/e10-forge-port-lift.md steps 1–2.

A pure mechanical, behaviour-preserving lift. forge.MRInfo and forge.ErrNotFound now
live on the port; internal/forge/gitlab keeps type MRInfo = forge.MRInfo and
var ErrNotFound = forge.ErrNotFound as transitional aliases; cmd/assent no longer names
either.

Out of scope (E10, per the design note): the forge.RunPort composite port and the
SyntheticDigest collapse onto Snapshot.Heads.MergeResultDigest (steps 3–5). No GitHub
adapter code (Locked per D-012).


1. gitlab. grep of cmd/assent, before and after

Code-level references only (gitlab.<Exported>, whole-line comments excluded — the tree
carries prose mentions of *gitlab.Client).

BEFORE (base 9e50e17) — 21 references, 5 distinct symbols
doctor_forge_test.go:71:   gitlab.New
doctor_forge_test.go:72:   gitlab.WithSleeper
doctor_forge_test.go:106:  gitlab.New
doctor_forge_test.go:107:  gitlab.WithSleeper
main.go:72:                gitlab.New
main.go:83:                gitlab.New
policy_test.go:121:        gitlab.MRInfo          <-- lifted
run.go:63:                 gitlab.MRInfo          <-- lifted (the port itself)
run.go:181:                gitlab.SyntheticDigest
run.go:452:                gitlab.ErrNotFound     <-- lifted
run.go:505:                gitlab.MRInfo          <-- lifted (decide)
run.go:528:                gitlab.MRInfo          <-- lifted (resolveRunApproval)
run.go:565:                gitlab.MRInfo          <-- lifted (mrFrom)
run.go:656:                gitlab.MRInfo          <-- lifted (buildDesired)
run.go:657:                gitlab.SyntheticDigest
run_render.go:20:          gitlab.MRInfo          <-- lifted
run_render_test.go:60:     gitlab.MRInfo          <-- lifted
run_render_test.go:63:     gitlab.MRInfo          <-- lifted
run_render_test.go:101:    gitlab.MRInfo          <-- lifted
run_test.go:462:           gitlab.New
run_test.go:463:           gitlab.WithSleeper

AFTER — 10 references, 3 distinct symbols:

doctor_forge_test.go:71,106:   gitlab.New            construction
doctor_forge_test.go:72,107:   gitlab.WithSleeper    construction
main.go:72,83:                 gitlab.New            construction (the production factory)
run_test.go:462:               gitlab.New            construction
run_test.go:463:               gitlab.WithSleeper    construction
run.go:193:                    gitlab.SyntheticDigest   E10 residue (see below)
run.go:671:                    gitlab.SyntheticDigest   E10 residue (see below)

cmd/assent/policy_test.go, cmd/assent/run_render.go and cmd/assent/run_render_test.go
no longer import the adapter at all.

Deviation from the literal acceptance wording — please note

The criterion reads "only the constructor site(s) remain". Two gitlab.SyntheticDigest
call-sites remain. This is deliberate and consistent with the rest of the story text:

  • the spec's own enumeration of the ~6 sites (run.go:61,420,473,496,533,624,
    run_render.go:20) maps — at a uniform +32-line offset on the base — onto exactly the
    MRInfo/ErrNotFound sites and excludes both SyntheticDigest sites;
  • the story explicitly puts the SyntheticDigest collapse in E10;
  • the merge-digest scheme is adapter-owned, so the fix is not "make it neutral" but
    "stop computing it in cmd" — E10 step 4 collapses both call-sites onto
    snapshot.Heads.MergeResultDigest, which Snapshot already computes. Doing it here
    would reorder what buildDesired depends on, i.e. not a mechanical lift.

Both sites carry an in-code note saying so, and the new gate pins the residue as an
allowlist (New, WithSleeper, SyntheticDigest) rather than waving it through:
anything else — notably MRInfo and ErrNotFound coming back — fails. E10 tightens the
list to New/WithSleeper; it is never widened without an ADR.

Second wording deviation: no .golangci.yml diff

REQ-AUD-S15-01 says "the S07 depguard rule extended in hack/lint/depguard_test.sh". The
script is extended; .golangci.yml is deliberately untouched. depguard is import-level
and this rule is symbol-level — cmd/assent legitimately imports the adapter to
construct it, so an import deny-rule would have to exempt run.go, the very file the story
is about. An unused import does not compile in Go anyway, so the symbol allowlist strictly
subsumes any file-level import rule. Leaving the config alone also avoids rescoping the
existing sed extraction (its - "**/…/**" and - pkg: "…" patterns are file-wide), which
would have put the working S07 gate at risk for no added strength.


2. Byte-identical goldens (REQ-AUD-S15-02, the refactor gate)

A 32-artifact deterministic capture was taken on the clean base 9e50e17 and again on
the same tree with this change applied, then diffed:

  • assent test and assent test --coverage on all three example packs (6 artifacts)
  • assent compare --suite on all three comparison suites (3)
  • assent lint + assent catalogue on all three packs (6)
  • the whole CLI help/usage surface: root + 7 subcommands (8)
  • go test -v over cmd/assent, internal/forge, internal/render,
    internal/core/aggregate, internal/core/decision, internal/adoptertest,
    examples/comparison, timings/ports/paths normalised (7)
  • the task determinism matrix (1)
  • git status --porcelain over every testdata/, examples/, schemas/ (1)
  • go tool cover -func over internal/forge/... (1)

cmd/assent's tests are the ones that matter most here: they drive runRun end-to-end
against a real *gitlab.Client pointed at an httptest GitLab, so they are the only
artifact that exercises the forge path at all (assent test/compare/lint never build a
forge).

Result: 30 of 32 artifacts byte-identical. The two that differ are fully accounted for:

artifact delta
gotest-forge.txt +16 lines, −0, ~0 — exactly the RUN/PASS lines of the new port_test.go. comm -23 pre post is empty: nothing was removed or changed.
coverage-forge.txt line numbers only (comments were added to gitlab.go). Normalising \.go:[0-9]+:\.go:L: makes it byte-identical: diff exits 0, every per-function percentage unchanged.

testdata-drift.txt is empty in both runs — no golden file was rewritten by any suite.

3. errors.Is through the real wrap chain

Preserving the chain and the rendered bytes constrained the sentinel's own message.
forge.ErrNotFound deliberately carries no forge: prefix — unlike every other sentinel
in the package, which is returned directly by forge code and names itself — because this one
is only ever returned wrapped by an adapter, which supplies the prefix:

// internal/forge/port.go
var ErrNotFound = errors.New("resource not found (404)")

// internal/forge/gitlab/gitlab.go — FileAtRef
return nil, fmt.Errorf("gitlab: %w: file %q at ref %q", forge.ErrNotFound, path, ref)

which renders gitlab: resource not found (404): file "x" at ref "y" — byte-identical to
the pre-lift adapter. A forge: -prefixed sentinel would have produced
gitlab: forge: resource not found …, a silent user-visible change no golden would have
caught. This is now pinned by a test.

internal/forge/port_test.go (new, package forge_test — an in-package test would be an
import cycle) drives a real *gitlab.Client against an httptest 404 and asserts on the
error the adapter actually returned — never a hand-constructed one:

  • errors.Is(err, forge.ErrNotFound) holds;
  • errors.Unwrap(err) == forge.ErrNotFound — the value, so a bespoke Is() shim that
    merely answers "yes" cannot pass;
  • the rendered message equals the pre-lift string exactly;
  • gitlab.ErrNotFound == forge.ErrNotFound (the alias is still an alias);
  • the httptest server was actually reached (a short-circuit would prove nothing);
  • negative control: 500 / 403 / 410 produce errors that must not match — otherwise a
    broken forge would read as "file absent" and the presence signal would fail open.

Plus TestMRInfoIsOneTypeAcrossPortAndAdapter (same reflect.Type, owned by
internal/forge, field list verbatim) and TestRunReadPortCarriesNoAdapterType, which
reflects transitively over the read port's method signatures asserting no
internal/forge/gitlab type appears anywhere in them — the ARCH-02 binding constraint made
executable.

4. Positive controls — every negative assertion can fire

Nine mutations were applied to the real tree (each verified landed via git diff), the gate
confirmed RED, then restored:

# mutation gate that went red
M1 drop the gitlab: prefix from the 404 wrap port_test.go message pin: got: resource not found (404): file …
M2 wrap an independent errors.New("resource not found (404)")message stays byte-identical errors.Is(err, forge.ErrNotFound) = false … the adapter did not wrap onto the port sentinel
M3a add a field to forge.MRInfo forge.MRInfo fields = […ForkMR Extra], want […ForkMR]
M3b replace the alias with a duplicate struct compile: *gitlab.Client does not implement runReadPort (wrong type for method GetMR)
M4a retarget the leak detector at a package the probe does not use positive control: adapterTypesIn found no gitlab type in a port that deliberately exposes gitlab.Option — the detector cannot fire, so its silence proves nothing
M4b point the real-port sweep at the leaking probe port the orchestration read port exposes adapter types [gitlab.Option gitlab.Client gitlab.RetryPolicy] (note the transitive walk)
M4c shrink the port below the vacuity floor runReadPort exposes only 1 methods — the port shrank and the sweep below would be near-vacuous
M5 put gitlab.MRInfo back into cmd/assent/run.go depguard gate: run.go:68: gitlab.MRInfoFAIL: cmd/assent names gitlab adapter symbols outside the construction allowlist
M6 break the scanner's regex FAIL: scan_gitlab_symbols found only 0 gitlab.<Exported> references in cmd/assent — the scanner stopped matching real code, so its silence would prove nothing
M7 alias the adapter import in doctor_forge_test.go (import gl "…/gitlab", refs become gl.New) doctor_forge_test.go:13: gl "…/internal/forge/gitlab"FAIL: cmd/assent imports the gitlab adapter under an ALIAS — the symbol allowlist above cannot see through it

On the depguard extension specifically. REQ-AUD-S15-01 says "grep-pinned", and it has to
be: the rule is symbol-level, and depguard is import-level — cmd/assent legitimately
imports the adapter to construct it, so an import rule would have to exempt run.go, the
very file the story is about. A grep that matches nothing passes open, so the new section in
hack/lint/depguard_test.sh carries three controls of its own:

  1. the scanner must see ≥ 4 real references in cmd/assent and the known gitlab.New
    and gitlab.SyntheticDigest call-sites (M6 proves this fires);
  2. a violating copy of the whole cmd/assent tree is materialised in $TMPDIR with an
    added file referencing gitlab.MRInfo/gitlab.ErrNotFound, and the scanner must report
    both symbols and name the violating file;
  3. cmd/assent must import the adapter under its bare path — an aliased import
    (import gl "…/gitlab") would make every reference read gl.MRInfo, the pattern would
    match nothing, and the allowlist would pass open. This closes the one evasion that
    reproduces the empty-sweep failure the other controls exist to prevent, and it gets its
    own control: a second probe file in the violating copy carries an aliased import that
    must be reported, while the unaliased probe must not be — a check that flagged every
    import would be silent on the real tree for the wrong reason;
  4. only then is the real tree's silence trusted.

Nothing is written into the repository working tree. Whole-line comments are blanked (not
deleted) before scanning, so reported line numbers stay true; trailing // … comments are
deliberately not stripped, because stripping from the first // would also truncate a
line containing a "http://…" literal and could hide a real reference after it — scanning
more text is the fail-closed direction. Block comments in cmd/assent are refused outright
with an instruction to extend the scanner first.

5. Gate matrix

Run on the rebased tree (base 669d805, after #25 merged):

gate result
task check exit 0, all 14 stage banners present, zero FAIL lines
task determinism green
task dogfood-examples green (all 3 packs, both polarities) — note this is not part of task check
bash hack/lint/depguard_test.sh green, all 4 polarity sections
go build ./... / go vet ./... green

CI on this PR is green: verify pass (5m48s), Analyze (go) pass, Analyze (actions) pass, CodeQL pass, MkDocs build pass, goreleaser snapshot pass. The table above is the local run.

The known PR #27 flake (internal/provider TestExecDigestPin/match_allows_exec,
TestIsolationNoWriteToken, signal: killed under -race load) aborted the first two
runs — once in test, once in coverage. It passes in isolation
(go test ./internal/provider/ -run 'TestExecDigestPin|TestIsolationNoWriteToken' → ok) and
this branch touches nothing in internal/provider. The runs reported above are complete,
unaborted runs.

Merge sequencing

Heads-up for AUD-S13 (internal/forge/clearslot_test.go): this changes types in
internal/forge / internal/forge/gitlab, but both are kept source-compatible by the
aliases — gitlab.MRInfo and gitlab.ErrNotFound still resolve, and errors.Is against
either still works. internal/forge/gitlab's own test files were not modified. A rebase
either way should be clean; this PR adds one new file to internal/forge/
(port_test.go, package forge_test, which the directory already mixes).

Files touched

internal/forge/port.go                         (new)  forge.MRInfo + forge.ErrNotFound
internal/forge/port_test.go                    (new)  REQ-AUD-S15-01 gate
internal/forge/gitlab/gitlab.go                       aliases + adapter-prefixed 404 wrap
cmd/assent/run.go                                     port + 4 signatures + fileAtRefOrAbsent
cmd/assent/run_render.go                              import swap + signature
cmd/assent/policy_test.go                             import swap
cmd/assent/run_render_test.go                         import swap
hack/lint/depguard_test.sh                            ARCH-02 symbol allowlist + controls
docs/planning/design-notes/e10-forge-port-lift.md     steps 1-2 marked shipped
CHANGELOG.md                                          regenerated

konih added 7 commits August 8, 2026 14:39
…-02)

AUD-S15 / REQ-AUD-S15-01+02. The orchestration read port `cmd/assent` depends
on spoke GitLab-named types — `GetMR(...) (gitlab.MRInfo, error)` and an
absent-file signal matched against `gitlab.ErrNotFound` — so a second adapter
could not satisfy it without cmd importing GitLab. Both now live on the port:

- `internal/forge/port.go` owns `forge.MRInfo` (moved verbatim, all seven
  fields) and `forge.ErrNotFound`;
- `internal/forge/gitlab` keeps `type MRInfo = forge.MRInfo` and
  `var ErrNotFound = forge.ErrNotFound` as transitional aliases (design note
  step 1/2), so the adapter and its tests are untouched;
- `cmd/assent` names only `New`, `WithSleeper` and `SyntheticDigest` from the
  adapter — construction, plus the documented E10 residue.

Zero behaviour change is the contract, so the 404 message is preserved to the
byte: the sentinel deliberately carries no `forge: ` prefix (unlike every other
sentinel in the package, which is returned directly by forge code) because the
adapter supplies its own, keeping the render at
`gitlab: resource not found (404): file "x" at ref "y"`.

Gates: `internal/forge/port_test.go` drives a REAL *gitlab.Client against an
httptest 404 and asserts errors.Is reaches forge.ErrNotFound through the actual
wrap chain (errors.Unwrap must arrive at the sentinel VALUE, so a bespoke Is()
shim cannot pass), pins the rendered message, pins the MRInfo field list, and
reflects over the port asserting no gitlab-package type appears in any
signature — with a deliberately-leaking probe port as the positive control, so
"found nothing" cannot mean "the walker is broken".

`hack/lint/depguard_test.sh` gains the ARCH-02 symbol allowlist. depguard
cannot express this rule (cmd legitimately imports the adapter to construct
it), so it is grep-based — and, since a grep matching nothing passes open, it
carries two controls: the scanner must see the real allowed call-sites, and a
violating COPY of cmd/assent in $TMPDIR must be reported by file and symbol.

Out of scope, per docs/planning/design-notes/e10-forge-port-lift.md: the
`forge.RunPort` composite port and the `SyntheticDigest` collapse onto
`Snapshot.Heads.MergeResultDigest` (steps 3-5, E10).
The note's problem statement described a state AUD-S15 has now fixed. Keep the
paragraph verbatim as the record of what was wrong, but label it pre-AUD-S15
and state what is still true today: only the gitlab.SyntheticDigest call
remains in cmd/assent, and it is grep-pinned to an explicit allowlist. Steps
3-5 stay open for E10.
Review catch on AUD-S15. scan_gitlab_symbols matches `gitlab.<Exported>`, so
an aliased import (`import gl "…/forge/gitlab"`) makes every reference read
`gl.MRInfo` — the pattern matches nothing, the allowlist reports zero
violations and passes OPEN. That is precisely the empty-sweep failure the
section's other controls exist to prevent.

aliased_gitlab_imports() now requires cmd/assent to import the adapter under
its bare path. One rule covers both import forms: a leading `import` keyword
is stripped first (sed preserves the line count, so reported line numbers stay
true), reducing `import gl "…"` to the grouped `gl "…"`, after which any
token before the quoted path is an alias and a plain `import "…"` has none.

Positive-controlled like everything else in this section: the violating copy in
$TMPDIR gains a second probe file with an aliased import that MUST be reported,
while the existing unaliased probe must NOT be — otherwise a check that flagged
every import would be silent on the real tree for the wrong reason.
@konih
konih merged commit e54a243 into main Aug 8, 2026
9 checks passed
@konih
konih deleted the lane/aud-s15-forge-port-lift branch August 8, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant