-
Verifier and evidence explanations now preserve the fact that produced them. (#436, #396, #414, #420) Plain blocked-run headlines name the deterministic worst blocker and bound untrusted multibyte titles by the control envelope's byte budget. A lower-confidence tool extraction without an enumeration defect is now
unattested_surface, distinct from an incomplete enumeration, and its repair asks for reviewed attestation. Policy gaps retain exact structuredpolicy_idwhile adopter prose omits engine-owned ids and derives mixed- evidence remedies from the authoritative basis actually present. Embedded trigger results preserve their evaluated state but clear commands from bothnext_actionandmatched_rules[]; verifier control remains the sole route. -
The pre-1.0 qualification corpus now has a committed sourcing plan. (#456)
benchmark/safety-qualification/strata-inventory.csvmaps the known candidate pool onto all 28 profile × decision cells thepre_1_0policy requires — 59 slots, each either a pinned candidate, an identified-but-unpinned one, or a gap with the lead that would close it. It is a plan, not evidence: it carries no label, no verdict and no receipt, nothing in it reaches the qualification runner, and it is not an admissible rater input, because it names a target decision for every slot.Each slot records its exposure — whether the engine was developed against that candidate — and holdout eligibility is derived from it rather than from where the candidate's bytes live. That is what the plan turns on: every vendor MCP server the adoption walks measured produced an issue, a fix, or a regression test, so the four best-understood candidates in the pool can only ever be tuning cases, and three
mcp_openapi_declared_bindingcells carry a third slot whose only job is to be holdout-eligible. The cell targeting drawn from miner labels is disclosed as not verifier-independent: the labeling worksheet shows the engine's own verdict columns, which biases which cell a candidate is aimed at (it does not reach the corpus label, which Amendment 1 raters produce blind).tests/test_strata_inventory.pyderives the grid and the holdout floor frompre_release_safety_requirements()rather than restating them, so a policy move fails the inventory instead of leaving it silently aimed at the wrong shape. It re-reads every cited miner label, pinned SHA, declared exposure, and candidate profile and merge state from the source that records it — so an origin cannot outrun the PR that supplies it (an open PR is not history, and a closed-unmerged one is notreal_history), and a profile cannot be changed on one side only.The plan's own shape is the finding: 26 of 59 slots have a candidate, the origin floor (23 qualifying cases) is the binding constraint rather than the case count,
n8nhas one sourced slot of eight, and the sweeps that produced the pool ran before #403 — so their zero-trigger repositories are unexplored, not empty. -
The determinism boundary is now a published specification, generated from the code. (#473)
docs/determinism-boundary.mdand its machine-readable companiondocs/determinism-boundary.json(shipgate.determinism_boundary/v1) state, for every built-in input and each of the four declaration shapes — export artifact, literal registration, factory, dynamic construction — what the scan reads, whichTool.source_typeit produces, the extraction-confidence ceiling that route reaches, and what that ceiling means for a release verdict.Nothing on the page is hand-maintained. Each adapter declares its coverage beside the code that mints its confidences; the consequence column is computed by asking the engine's own completeness predicates about those declared facts, so the page cannot claim an outcome the engine does not reach. Generation is fail-closed in both directions: an adapter registered without coverage, and a source type added to the engine's ceiling vocabularies without a route, both break the build rather than being omitted from the page.
python scripts/generate_schemas.py --checkenforces that the committed page equals the regenerated one, as it already does for the schemas.Every
insufficient_evidenceverdict now links to it — inscanstdout,verifystdout, the GitHub step summary, andreport.md— so an honest abstention reads as a scoping answer rather than a dead end..well-known/agents-shipgate.jsonpublishes both URLs.extraction_is_complete()is now the one definition of "the adapter read this tool's contract with full confidence", shared by the semantic resolver,low_confidence_tool_count, and the boundary generator. -
One capability schema, frozen before either surface that will ship it. (#469) Two planned public surfaces serialize the same internal truth: the exported capability delta published as a standalone attestation (#470) and the committed capability state (#474).
shipgate.capability_payload/v1is now the one payload both consume — a JSON Schema (docs/capability-payload-schema.v1.json), a prose spec (docs/capability-payload.md) stating the identity keys, the required/optional split and the evolution policy, and one projection (agents_shipgate.core.capability_payload) that fills it. One document, two views discriminated onview:statefor a point in time,deltafor the movement between two.A payload cannot contradict itself.
subject.keyis recomputed from the row's own agent/provider/tool id — deliberately not from the subject kind — so a tool and its action are one row and two rows cannot split one logical tool between them. Subjecttransitionis a statement about presence (present_in_base/present_in_head), never a rollup of change kinds: a tool that keeps one operation and loses another ismodified, because it is still there.summary, every transition, everychanged_dimensions, and a state's digests are recomputed on parse, and a payload that disagrees with its own rows is rejected rather than repaired. A change entry cannot claimevidence_onlywhile its two published records differ in semantics, so a permission expansion the fact layer folds intoevidence_hashalone can never be published as provenance-only.The format is specified for consumers that are not this program. Canonical bytes are UTF-8 and unescaped, with sorted keys, integers bounded to the I-JSON safe range, and no fallback serialization — so a Python and a JavaScript implementation compute the same digests, and the spec publishes cross-language vectors. Every object key is ASCII by enforcement rather than by assumption:
capability_idis the one dynamic key and is constrained to its canonical form, because Python orders keys by code point and RFC 8785 by UTF-16 code unit and those disagree above the BMP. The reference parser uses strict scalars, so it accepts exactly the language the published schema does — no"2"for an integer, no"false"for a boolean. Every field is required in the schema'srequiredarrays, not merely in prose — a version field or aviewdiscriminator a consumer may omit and have repaired is not one. Validation is explicitly two stages: everything JSON Schema can express is in the published file, and the rules that need a recomputation are enumerated as stage two in the spec and in the schema's own description.analysis_coveragecarries the subjects the analysed surface left out — an added-but-unbound tool produces no capability fact, so without it the first surface that had to report one (#437) would have needed a second payload shape. A delta carries both sides plus the recomputednewly_outside_analysis, because one snapshot cannot tell a newly unbound tool from one that was already unbound.statusisnot_requested | unavailable | complete, neither of the first two means zero, onlycompletemay name subjects, and a comparison is only as established as its weaker side.The published field set is closed: every model forbids unknown properties, each internal field the payload does not publish is recorded with its reason (
UNPUBLISHED_FACT_FIELDS,UNPUBLISHED_LOCK_FIELDS), and tests assert those maps cover everyCapabilityFactV1field and that every published field keeps the internal field's type — so a widenedLiteralis a schema decision someone makes, not aValidationErroran adopter discovers. Because the set is closed,v1is closed: any addition is/v2, and the spec says so instead of promising an additivity the shipped validators do not implement.The semantic fields are derived, not asserted.
semantic_directionandsemantic_changesare computed from the two records a change entry carries, dimension by dimension, and a payload declaring anything else is rejected — relabelling a rowbroadenedor deleting its explanations both validated before. The direction is therefore the direction of what this payload publishes, andevidence_onlymeans exactly "the two records are equal apart from provenance". The change record is a payload-owned type rather than the internal report model, so its values stay inside the canonical domain and a future internal field cannot widenv1unnoticed.A state publishes three digests — semantics, provenance, and coverage — together binding everything it publishes, and verifies its own on parse. The two state refs of a delta are bound to its membership rows, so a head ref cannot claim counts the rows do not support. The permission block shares one classifier with
mcp audit, is restricted to shapes that classifier can produce, and is fail-closed when unmeasured. That classifier's class ordering is now total:financialandproductionshare a rank, so a rank-only sort inherited hash-randomized set iteration and two runs of the same repository could publish different bytes.Both projection entrypoints snapshot their inputs before reading them. They walk each side several times, so a caller-owned list that answered differently on a later pass produced a payload whose rows and whose digests described different revisions.
Nothing emits the payload yet, by design: no command, no artifact, no check, no change to
contract_version,report_schema_version,.well-known, or any existing schema. The capability lock and lock diff are unchanged, andrelease_decision.decisionremains the only release gate. Worked state and delta examples are generated fromsamples/ai_generated_refund_prbyscripts/generate_schemas.pyand gated on drift. -
Replayable incident fixtures turn first-contact activation into a verifiable product path. (#471)
fixture runnow materializes three PR-shaped histories and feeds them through the real verifier:agent_weakens_gateblocks removal of the Shipgate workflow,prompt_change_rides_releaseroutes a protected prompt change beside ordinary release metadata to human review, andgoverned_edits_governanceopenly records the still-unshipped.github/agents/**path boundary as an expected-fail owned by #474. The expected-fail contract fails closed when the report is absent and only declares the gap resolved when a review finding names the missing path.The v0.18.0 package will include the replay metadata and synthetic changes without duplicating protected sample manifests.
fixture list --jsondistinguishes replay identities from their backing sample paths, andfixture copywrites an incident-specific README. A source-checkout command is documented until that package exists, so first contact never invokes a PyPI version that cannot know the fixture name.The headline metric is activation: each public incident shape now has a one-command, deterministic verifier result plus a reusable response-article template and a filled evidence-disciplined example. No detection engine, schema, check ID, or release-decision rule changes in this work.
-
Reviewed risk overrides no longer masquerade as scan observations. (#460)
risk_overrides.tagsis excluded fromeffect_readingsand the derived actionbasis, so adding or removing a reviewed tag no longer reopens a pinned declaration. A tag still constrains any proposal unlocked by genuine source evidence, but cannot seed one by itself. Declarations pinned by an older release while an override was present may surface one deliberatedeclaration_driftso a reviewer can confirm the corrected source-only pin. Proposal questionnaires now disclose when a filled tag list replaces an existing reviewedrisk_tagsfield, including an explicit empty list, and scanrun_idbinds that key-presence because it changes whether the draft is coding-agent- or human-owned. -
Capability delta now answers the reviewer’s question in subjects, without changing its machine contract. (#437, #439) PR comments group the complete analysed
capability_changeby reader subject before applying their display limit, so one bound tool that producestool_addedandaction_addedis shown once with both changes instead of as two capabilities. Added, modified, and removed subject counts are memberships over that grouped set; a subject changed in more than one direction keeps every direction. Groups are ranked by their worst release impact and then change direction, every selected group keeps its distinct change types visible, and truncation names the exact hidden subject and underlying-change counts.The adjacent exclusion is now explicit rather than folded into capability: when a successful base comparison finds
binding_surface_diff.added_unbound_tool_ids, the same line separately says how many subjects are newly outside the analysed surface. That neutral wording covers both an added-but-unbound tool and a previously reachable tool that lost its binding. Pre-existing unwired catalog operations, including the large sample’s 58 intentional operations, add nothing; a plain scan has no base-relative term; and a requested comparison that could not run reports the outside-analysis value as unknown rather than zero.Both PR-comment styles and cold-reader ordering use one projection. The existing
verifier.jsoncapability_review.added,modified,removed, andtop_changes[]fields remain change-record compatibility fields, and verifier/report schemas, release decisions, and gate behavior are unchanged.The human projection recovers canonical tool identity from the report's existing action/tool facts and diffs, so same-named tools from different providers remain distinct while a legacy row with ambiguous identity is not guessed onto either tool. Reader detail resolves canonical action hashes to operation names and retains semantic rationale. Group provenance is labelled explicitly and rendered as Markdown-safe code spans; exact hidden counts are emitted before bounded subject rows, so long repository-controlled names cannot erase the truncation disclosure.
-
Cold-reader artifacts now lead with what the agent can do. (#463) On a repository with no committed Shipgate manifest, human output starts with the root-reachable tool surface, its effect breakdown and write/destructive action names; then shows a subject-grouped capability delta when one exists, subject-grouped findings, and the unchanged release decision. Console, multi-manifest console rows,
report.md, GitHub step summaries, PR comments, and packet Markdown/HTML/PDF share that ordering. A cold run carrying any active block-tier content remains verdict-first, and committed-manifest repositories retain their prior bytes and verdict-first order. Cold reports render the primary decision exactly once; adopted reports preserve the pre-existing capability-consequence projection as part of their byte-stable layout. Machine JSON, schemas, contracts, and decision computation are unchanged.The ordering is lossless: tool-surface and action/tool-diff detail still appears below the decision. Compact packet finding lists share one bounded truncation projection, write/destructive action names are capped with an explicit omission count, and repository-owned names are display-encoded so they cannot forge a decision line. PR comments consume the scan's exact ephemeral repository context, including manifest introduction, rather than re-probing or depending on report-evidence fallback.
samples/google_adk_cold_start_agent/expected/cold-report.mdis the committed first-contact golden: its test creates a Git repository whose agent sources are committed whileshipgate.yamlis not, then byte-compares the rendered artifact. The same suite pins an empty repository, first-adoption verifier output, packet ordering, cold exact-once decision rendering, the block-tier override, and adopted-sample byte stability.This release covers report-backed scan and verify artifacts.
verify --previewremains a routing-only operation and produces no readiness report to reorder; the local-review surface described in #326 is not implemented by this change. -
The #424 repair loop is now pinned by a committed artifact. (#424)
declaration_below_inferred_evidencepublishes adeclare_risk_tagsroute, and every guard on it ran against tools built in-test — the same blind spot that let the class ship in the first place.samples/google_adk_cold_start_agentcould not close it: a cold start has no declaration to challenge, so a challenged row is the one questionnaire shape it cannot render.samples/declaration_repair_agentis the step after that cold start. Every action is declared, controlled and owned, and two rows are challenged — one bare, one already carrying a reviewedrisk_tagsentry — so the sample sits atinsufficient_evidencewith exactly two open questions. Pasting the two blocks its committedexpected/suggested-declarations.yamlpublishes, each into the action it names and nothing else, reachespassed.The already-tagged row is the one that earns the second slot:
risk_tagsis one YAML key, so a block naming it replaces it, and a repair publishing only the newly uncovered category would tell that reviewer to delete their own reviewed tag. The committed golden publishes the complete value, and the guards close over both routes a regression can take — reverting the fix fails the byte comparison, and regenerating the golden to make that pass fails the paste test instead, naming the row it reopened.The same "publish the whole list" property is now asserted on the surface an adopter reads first. #424 reached a reader as a sentence, not a YAML block, and
EffectRepair.instructioninterpolates the whole list and the newly added part separately — soevidence_gaps[].next_action.expectsis joined to the block'srisk_tagshere. This is the first row in the repo where the two can disagree; the only other test pinning that sentence uses a row with no pre-existing tags, where they are equal by construction.No engine, schema or check-ID change: goldens, registration and tests only.
-
MCP clients can now see when a server's reassuring annotation contradicts the evidence beside it. (#462) A tool that publishes
readOnlyHint: truewhile independent structural or explicitly labeled inferred evidence says it can write, ordestructiveHint: falsewhile evidence says it can destroy, now raisesSHIP-MCP-ANNOTATION-CONTRADICTION. The finding is review-tier: it does not weaken or replace the conservative permission verdict the engine already computes, but it names the hint, the contradicting evidence and its basis, the tool's source pointer, and the under-prompting consequence for MCP clients that consume the annotation directly.Absence still follows the MCP defaults and never fires. A source's
tool_sources[].trustsetting cannot make a published contradiction true, while a reviewed action override suppresses only the exact inferred claim IDs it answers. Policy-eligible structural evidence now corroborates a narrowing hint against weaker keyword or pattern noise, while structural side-effect evidence still triggers review. Reviewed effect declarations and mutually conflicting hints from the same source stay on their existing semantic-evidence routes instead of being counted as independent evidence.Inferred-only instances remain detailed, informational findings with
support.policy_eligible: false; the scan also emits their evidence gap, but release accounting excludes them from blockers and named review items. With a base report, the delta form separately proves that independent evidence is unchanged, detects any exact client-visible annotation-map change, and names a hint flip only when reconstruction matches the base hash. The hash no longer uses finding-fingerprint normalization, so otherwise ignored keys and list order cannot produce a false proof. Static, exact-delta, co-change, corroboration, trust, reviewed-override, source-provenance, end-to-end scan publication, and adopter-vocabulary regressions pin the behavior. -
The declaration questionnaire is now pinned by something committed. (#425) It is the primary cold-start surface — what an adopter at
insufficient_evidencereads, in order, to reach a verdict — and no shipped sample exercised it. Every sample answered every question it was asked, soopen_questionswas[]in all five goldens,suggested-declarations.yamlhad no golden at all, and the progress sentence was only ever rendered at zero. That is how #419 shipped an ordering that put a structurally proven OpenAPIGETnameddelete_accountat the top of the file, ahead of a genuinely unknown effect, against a fully green suite.samples/google_adk_cold_start_agentis the sibling ofsamples/google_adk_agentthat deliberately stops partway: ten open questions across the rungs the ordering distinguishes — actions nothing was read about, one where only the MCP protocol default stands in for evidence, one read as a financial write so its block arrives with a proposed answer, one source-widetool_sources[].authorityblock answering for four actions at once, one the counter counts but no block can answer, and a structurally proven read whose name bands as a write. Both halves of #419's fix are held: the unbounded questions lead the bounded ones, and among the unbounded the name band orders them against alphabetical order in both directions, so flatteningname_shape_bandmoves the committed file too.Its
expected/suggested-declarations.yamlis byte-compared the wayreport.mdalready was,open_questions[].*now reachestest_sample_expected_report_json_has_no_structural_drift, and the gap order the decision reason andfirst_recommended_actionproject is asserted against the questionnaire's own numbering. Reverting_reachincore/declaration_questions.pyto rank byconservative_effectfails two committed-artifact tests instead of nothing.Test evidence only: no CLI command, schema version, report block, discovery surface, or adapter changes.
-
A reviewed risk tag is the manifest refining its own row, not source evidence contradicting it. (#424)
declaration_below_inferred_evidencepublishes two routes, and the second is the one the row names: "addaction_surface.actions[].risk_tags: [X]so the X controls apply to this action". Applying it exactly as instructed replaced the review-tier row with a blockingconflicting_effect_evidencewhose message blamed the reviewer's own manifest — the published next step could not close the row it was printed on. Measured over every declared effect against every one-, two-, and three-observation combination: 281 of the 390 pairs that take the tag route. First-time adoption was never affected; a proposal always raises the effect or names a covering set, so only the post-declaration repair was broken, and it was broken exactly where the two published rank tables disagree — the case the two-route design exists for.Two branches of the resolver disagreed about what a reviewed
risk_tagsentry is.claims_above_declared_effecttreats it as covering, deliberately: a declared tag is policy-eligible, so it both accounts for the observation and makes that category's built-in controls apply. Thecontradictoryfilter ran first and read the same claim as source evidence outranking the declaration. It now excludes the two spellings of "declare this category as reviewed" — the action row'srisk_tagsand therisk_overrides.tagshint that says it once for a whole selector. This is the same class already fixed one branch over in_source_read_conflict.Deliberately narrow. The exclusion is not
_is_manifest_ownedwholesale. That predicate also coversaction_scope, and a declared permission list is a different kind of statement: #417 made a declaredcrm.deletegrant bound the action's effect, so excluding it would re-open that fail-open. A declared tag refines the effect the same person wrote; a declared grant asserts an independent fact that bounds it. Protocol annotations, a source's own scopes, and typed provider facts keep contradicting a weaker declaration exactly as before.The repair names the whole
risk_tagsvalue, not the additions.risk_tagsis one key, so a published block naming it replaces it. Naming only the newly uncovered category asked a reviewer whose row already readrisk_tags: [financial_write]to delete the tag covering the financial reading, and the next scan reopened the row asking for it back — the same defect, in the case the repair itself creates. The declared list is now carried verbatim on the declared-effect claim (it cannot be rebuilt from the tag claims:read_only,network_access, andcustomer_datamap to no positive effect and produce none), andEffectRepair.added_risk_tagscarries what changed so the sentence and the value cannot disagree.The gate verdict this moves.
effect: read+risk_tags: [destructive]goes from blocking to pass-eligible. It is not a downgrade: the action still resolves todestructive, the destructive claim is still policy-eligible, and besideeffect: read— which obliges nothing — the two spellings publish identical action and capability facts and reach the same decision and findings. Beside a positive effect they are not interchangeable, and the contract now says so: a tag adds its category, soeffect: external_communicationwith a financial tag owes confirmation as well as approval, audit, and idempotency, whereeffect: financial_writealone does not. The P0 canary that pinned the old outcome (manual_financial_tag_cannot_downgrade_to_read) is replaced in its slot by the boundary the fix draws,declared_delete_scope_cannot_downgrade_to_read, and the property it guarded is asserted in its new form beside it.A read claim no longer synthesizes a
read_onlytag (#461). The action fact unions a risk tag for every policy-eligible effect claim, andreadis not a category — it is the assertion that none apply. A row declaringeffect: readbesiderisk_tags: [financial_write]therefore publishedread_onlyon afinancial_writeaction, andderive_side_effectreads that tag as positive evidence:reversibility: reversible, the one thing that helper's own docstring says a declared read must not buy. Pre-existing, and folded in here because this change is what moves the wrong fact into a report that can pass. Actions with that spelling lose theread_onlyentry from their publishedrisk_tags, so a stored lock covering one shows a singlerisk_changed/narrowedrow on the first scan after upgrading — the safe direction, and correct: the action stops claiming to be read-only. Carrying the declared tag list on the declared-effect claim likewise moves that claim'sevidenceandclaim_idfor rows declaring both aneffectandrisk_tags; anevidence_hash-only change is classifiedevidence_onlyby design and carries no direction.Two more sites read the manifest as the source, found reviewing this change. The manifest reaches the effect dimension by two routes — the action row, named by
DECLARATION_CLAIM_SOURCES, andrisk_overrides.tags, which arrives asrisk_hint:manualcarrying areviewed_declarationbasis and no declaration source. Two comparisons excluded the first only:SHIP-ACTION-EFFECT-DOWNGRADE-DECLAREDderives "the effect Shipgate inferred" from the claims that are not the manifest's. A reviewedrisk_overrides.tags: [destructive]beside a source that says onlywritewas reported as Shipgate's own inference, in a recommendation telling the reviewer to declare the value they had already written.declaration_below_inferred_evidencenames the evidence that agrees with the declaration, in a sentence that says "source evidence agrees with the declaration" in so many words. Arisk_overrides.tagsentry matching the declared effect was named there — the manifest confirming itself, which the comment above that filter already forbade.
Both now ask one predicate,
is_manifest_owned_effect_claim, promoted from the private helper_source_read_conflictalready used so a fourth site cannot spell it a fifth way. -
A
0.xtag now has an evidence bar it can actually meet, and it is not a weaker judgement. (#341) The only release policy was 100 adjudicated, receipt-bound cases — the claim1.0should make — enforced for every tag. No corpus met it, so nothing published, and evaluators kept installingv0.15.0: an older, less-verified build than the one being withheld.A second named policy,
pre_1_0, governs0.xtags: 56 cases, two in each of the same 28 profile × decision strata, with the origin floor at the same 40% share. Everything that decides whether evidence is believed is byte-identical to the production policy — zero unsafe auto-passes per profile and overall, a unique terminal verifier receipt per case, the 20% holdout fraction, the κ floor,static_only, and full re-derivation by the verifier. Every exact-match floor is the production rate rounded up, which at 56 cases means three of the four land on 100%: a smaller corpus buys less tolerance for error, not more. The route, the numbers, the rejected alternatives and the promotion path are recorded by a named owner indocs/release-evidence-policy-decision.md.The version decides the policy; the artifact never does. Epoch 0 with major 0 admits
pre_1_0or the strongerbeta; everything else — including a version that will not parse — admitsbetaonly. Both release gates derive this independently, and an artifact naming a tier its version does not admit is rejected and then measured against the production counts, so a bad tier cannot shrink what is checked.production_qualifiedkeeps meaning "met the 100-case bar": apre_1_0artifact reports itfalse, and claiming otherwise is itself a rejection.The sealing gate was the sixth definition site, not the fifth. The standing brief listed five places the bar is defined;
verify_qualification_binding.py— the standard-library gate that seals a release without importing the project — hard-codedREQUIRED_CASE_COUNT = 100andtier == "beta"independently. A change that moved the other five would have failed there, at the last step before publication, with a bare case-count error. It is per-tier now, andtest_the_stdlib_case_counts_match_the_named_policiesbinds its restated numbers to the real constructors so the two copies cannot drift. Along the way the exhaustive verifier stopped hard-coding100in six places and now derives every count, metric denominator and confusion-matrix profile from the governing policy. Review turned up a seventh:benchmark/safety-qualification/README.md, the corpus owner's runbook, which decides what actually gets built — a change that left it behind would have aimed the whole corpus effort at the wrong shape, which no gate can detect. It now documents both policies, and its pre-existing claim that receipts must carry report schema0.40(the gate pins0.42) is corrected.production_qualifiedis now unrepresentable when it disagrees with the tier. Every gate rejected such an artifact, but only after it had been signed and handed to the release.SafetyQualificationResultV1refuses to construct one, so the producer cannot emit it — and the version rule binds therequirements=keyword too, not just--policy-tier, which previously let a caller score a1.0wheel against the pre-1.0 policy and exit0.Review found three more, all in the parts that decide what publishes. The sealing gate restated only a case count, so it could not tell 56 correctly stratified cases from 56 identical ones, and accepted a corpus two safe passes below its floor that the exhaustive gate rejected — the dependency-compromise boundary it exists to hold was decorative. It now restates each tier's strata, exact-match floors, per-stratum holdout, and the origin and κ floors, re-derives them from the raw cases, and has every field bound to the real constructors by test. The version→tier rule was anchored only at the start of the string, so
0garbage,0.16.0garbageand0..1bought the cheaper policy — the exact inversion of the documented fallback, in a helper both gates share; it now requires a complete PEP 440 parse. Andqualification_tier: pre_1_0plus the new cross-field invariant are grammar changes emitted under a frozen envelope id, soshipgate.safety_qualificationadvances v4 → v5, with v4 still readable (its vocabulary is a strict subset) and the corpus and receipt-index envelopes deliberately unmoved, since their grammar did not change.A second review round found the bump had a hole and the sealer had four more. Upgrading a legacy envelope unconditionally rewrote v4 to v5 before anything looked at the tier, so a conforming pre-1.0 artifact could keep claiming a v4 an old reader cannot parse — the exact combination the bump existed to eliminate. A legacy envelope is now read only when the payload uses the vocabulary that envelope can express, and both gates enforce the pairing. In the sealer: floors count matches, so a case with a null
actual_decisionmerely failed to count toward its floor rather than being rejected, and 56 rows sharing one id looked like 56 cases — case identity and terminal decisions are now checked before any floor.>= 0.80admittedinf, since the JSON literal1e309loads as a float that satisfies every lower bound; κ and the origin count are now bounded on both sides and required to be finite and integral respectively. And the report schema version has no representation incasesat all, so the approved0.42could be restated as0.1and still seal — the sealer now compares the artifact's whole declaredrequirementsblock, field for field, against its restatement of the policy.The 1-tuning/1-holdout split was documentation, not policy. What is enforced is a per-cell holdout floor; a corpus that marks more cases holdout is accepted, deliberately — holdout evidence was never tuned on, so more of it is stronger, and a floor on tuning cases would be a ceiling on holdout. The decision document, the schema docstring and the corpus runbook said "leaves each cell one tuning and one holdout case" as though that were checked; they now say what is actually enforced and why.
-
One control vocabulary reaches both streams, and the adoption walk composes end to end. (#323) v24 rolled
shipgate.agent_control/v1acrossdetect,init, anddoctoron stdout and left the error stream out.doctor's failure routes picked it up during that rollout;detect's and five ofinit's did not. So whether a caller that routes oncontrolcould route at all depended on which setup command had failed and on which of its failures — and the run that most needs a route is the one that printed no payload to carry it. Every agent-mode error line from those three commands now carries the same envelope its--jsonpayload would, projected from the same selected route asnext_actionandnext_actions[], so the three cannot disagree. What every setup error line guarantees is what the schema enforces for any setup envelope:decision_source: "setup", a decision from the setup vocabulary, every field ofpermissionsfalse, and nevercontrol_state: "complete"— theexecutionsplit is described below. Two lines still carry none, by design and by documentation: the shared--workspacerefusal fires before a workspace exists, andenvironment_erroris emitted before Agents Shipgate is running. Runtime contract 26 → 27; both theAgentControlunion and the envelope schema are byte-identical, sominimum_control_contract_versionstays at21.The route that could not advance.
init --writeover a manifest that already exists publishededit shipgate.yaml,expects: "The manifest reflects the desired tool sources, agent declared_purpose, and policies"— a postcondition the file already satisfied, because a manifest that does not load is claimed by the repair route above it. On this contractnext_actionis the step, so an envelope-only caller opened the file, found nothing to change, re-ran, and got the identical action back forever. That is the one place the #327 adoption walk could not leave stage 2: after a human resolves thedeclared_purposedeclaration, re-running the command that stopped is the only resume an envelope-only caller has. The route is now thedoctorinvocation for the manifest on disk, which reports what that manifest still owes. The exit code and the sentence a person acts on are unchanged.A refused write must not lose what the run asked for.
init --writeover a manifest that already exists hands the caller onward — to the gate on the refresh path, todoctoron the plain one. Both read a manifest that loads fine and advance under the pack it selects, soinit --write --control-pack financial-strictover adefaultmanifest proceeded underdefaultwith nothing in the route saying the request had been dropped. That route now namespolicies.control_packand the exact value, and only reaches the onward step when the manifest already matches. For the same reason, every recovery commandinitpublishes repeats the whole invocation with only the invalid value corrected: they were built from the smaller flag list a rerun in a different workspace may repeat, soinit --write --minimal --control-pack <bad>emitted a recovery without--minimaland following it wrote a detected manifest where a legacy template was asked for.A pack change an agent asked for is not a pack change a human approved. The reconciliation route above was published as a coding-agent
editbecause the value came from the command line rather than from inference. A governed coding agent composes its own argv, so that is not authenticated human provenance:init --write --control-pack read-only-agentover afinancial-strictmanifest dropswriteandproduction_operationobligations, and an agent could have requested it for itself. The direction decides the owner now — a transition that keeps every obligation the manifest has today is an agent edit, one that drops any obligation (or names a pack this build cannot resolve) is a human review naming what it would remove. The comparison isweakened_pack_obligations, moved beside the packs it compares and shared with thecontrol_pack_weakenedcheck rather than restated."Asked for" is read from the argument parser, not inferred by comparing against the default: an explicit
--control-pack defaultover afinancial-strictmanifest is a request, and the only one that can only weaken.Scoped routing owed the same two things. The capped retry is the one scope route that reruns the same workspace, and it was built from the list a rerun in a different one may repeat — so it dropped
--allow-unresolved-scopeand returned the refusal it was issued to resolve. Candidate commands dropped a raised--max-python-files, which bounds how much is read rather than which directory, so a candidate inherited the root's truncation and refused again while itsexpectspromised a manifest. And an adopted candidate whose manifest selects a different pack went todoctor, losing the request one directory down. There are now two lists with one derivation — what transfers to another workspace, and that plus the two that only make sense in this one.executionis not a marker for "this line is an error". Theerrorfield is.executionsays whether the command reached an answer about the workspace, and both values occur on error lines:"failed"where it could not,"succeeded"beside a non-zeroexit_codewhere it did and the answer is a refusal it can route past —config_already_exists, the resume of the adoption walk itself. What every setup error line does guarantee, in the published schema:decision_source: "setup", a setup-vocabularydecision, everypermissionsfield false, and nevercontrol_state: "complete".Proved by walking it.
tests/test_adoption_walk.pytakes an unadopted repository fromverify --previewto a release decision as real subprocesses, choosing every step from the envelope alone — no command-specific result field, no hand-built command, no look at the workspace to work out which stage it is in. It asserts the invariants #323 asks for at each step (one vocabulary, one typed rank-1 action, setup and gate states naming their source, setup authorizing nothing, the gate's decision equal toreport.json's) and fails a step that hands back the same action for an unchanged subject, which is how the route above was found. The recovery routes are followed rather than read: the test runs the emitted command and looks at the manifest it produced. -
The declaration continuation: a drafted proposal can now reach the person it was drafted for. (#429 review)
apply-patches --kinds declare_actionwrites intoshipgate.yaml, which is the trust root — so the control that authorized it is superseded the instant it lands, and the rerun is a fresh decision over a manifest that now says more. When the declaration is the thing that makes a risk judgeable, that decision isblocked, and a blocked decision authorizes nothing. The proposal Shipgate itself drafted could therefore never be committed, pushed, or put on a PR: the §D loop could not finish.apply-patchesnow leaves a receipt beside the report it applied from —declaration-continuation.json,shipgate.declaration_continuation/v1— pinning the manifest by byte digest on both sides of the write, and naming the rows it wrote. A later working-tree run honours it only when both digests still match and the two manifests it names differ by nothing but addedaction_surface.actionsrows. On that proof the blocked run isreview_publishable:edit,commit,push,update_pr—mergeandreport_completestill denied, so the gate is exactly as strong as it was. Without a receipt, a blocked decision authorizes nothing, as before.Coordinates, and the temporal window. Both halves of the receipt are recorded against
report.manifest_dir— the coordinate system a scoped monorepo manifest lives in — and are resolved through it rather than against the repository root or, worse, the process directory. A first adoption has no earlier manifest for a digest to name, so the receipt records the absence and the introduction proof carries the claim there. The introduction proof itself now reads the merge base, like every other half of it: asking the base tip let a diverged history wheremaindeletes a gate the merge base still carries read as an adoption. And the applier's second authorized shape — filling the fields an existing row leaves silent — is accepted alongside an appended row, because a parsed manifest spells "silent" as a present key with aNonevalue and a raw comparison read the fill as a changed answer.It is provenance, not a signature, and the bound is deliberate. Anyone who can write the manifest can write a receipt whose digests match it. What they cannot do is make the delta parse as declarations, so a forged receipt buys putting a manifest change in front of a human — which is the thing it is for — and never a loosened gate.
verifier.json,agent-handoff.jsonandverify-run.jsoneach carry the resultingdeclaration_continuationboolean, which is what lets their shared publication invariant tell this case apart; their schema versions move to0.15,v8andv5, and the runtime contract to26. -
The route is reachable on a first adoption, and states the only order the protocol allows. (#429) §D's
declare_actionroute was refused oncapability_review.policy_weakened— the fail-closed routing flag, which stays raised whenever the direction of a policy change could not be established. Establishing it means proving that no file in the tree parses as a manifest under any name, and that proof reads the tree: one blob past the probe's per-candidate read bound ends it, andgoogle/adk-samples, the adoption-walk target, carries 35. So a first adoption — the run with all thirteen questions open, three of them agent-answerable — was the one run that could never be offered the route that answers them.The route now asks a separate, cheaper, sound question instead: does this diff introduce the gate it is judged by? The configured manifest is in the evaluated diff, absent at the comparison ref, the diff deletes and renames away nothing at all, and every policy input this run resolved — the fixed trust-root globs,
checks.policy_packs[].pathat any legal path, and the--policy-pack/--baselineinputs — is that manifest. A proven weakening refuses regardless. The removal check is suffix-agnostic and asked over the comparison the run evaluated, because a gate may beold-gate.jsonand a staged rename is invisible tobase...head.Deliberately nothing else moves: the whole-tree content probe and its bounds are untouched, so
policy_weakened, the verdict, the adoption wording and the human route all read exactly as before. What moves is only who may draft the blanks, into a manifest a human still merges.Order. The route's instruction was "apply, commit, re-run". But the command supersedes the control that issued it, so the permissions printed beside the route cannot authorize publishing what it just wrote. It now says re-run first and act on what the re-run authorizes, and the typed
expectspromises the write and the supersession rather than a commit. When that refresh refuses, the recovery it advertises is the producing run's own exact local rerun — recovered from the artifacts the refusal had already validated — rather than a fixed--base origin/main --head HEADthat needed a remote-tracking ref, scanned committedHEAD, and missed the very edit that superseded the pointer.A withheld route names itself.
report.jsonpublishes every open question withauthorable_byresolved whether or not the route is published, so a control that offered nothing and explained nothing read as an invitation to edit the trust root without the route. The headline — and socontrol.reasonandcontrol.next_action.why— now carries for example3 declaration(s) this scan could draft are withheld: a blocker is open, and that decision is a person's.One pass produces both the refusal and the cause, open blockers are checked before the verdict so a blocked report names the blocker, and only a comparison that actually ran may say the gate was weakened. It is headline context: it shares the 400-byte prose budget, is dropped whole rather than truncated, and yields to the gap-provenance clause. Do not branch on its presence. -
The declaration route is reachable on a first adoption. (#429) §D's
declare_actionroute was refused oncapability_review.policy_weakened, which is the fail-closed routing flag: it stays raised whenever the direction of a policy change could not be established. Establishing it means proving that no file in the tree parses as a Shipgate manifest under any name — and that proof reads the tree, so a single blob past the probe's per-candidate read bound ends it.google/adk-samples, the adoption-walk target, carries 35 such blobs. The result was that a first adoption, the run with all thirteen questions open and three of them agent-answerable, was the one run that could never be offered the route that answers them.The route now asks a separate, cheaper, proven question instead: does this diff introduce the gate it is judged by? The configured manifest is in the evaluated diff, it is absent at the comparison ref, the diff removes or renames away no YAML file, and every policy surface it touches is that manifest. When all four hold there is no prior version of this gate the change could have loosened, whatever some other file in the tree may be, and the fail-closed flag is raised only because nothing could be compared. A proven weakening refuses the route regardless.
Deliberately nothing else moves. The whole-tree content probe and its bounds are untouched, so
policy_weakened, the verdict, the adoption wording, the self-approval headline and the human route all read exactly as before — a base that retains an operational manifest under another name still refuses to be called an adoption, at any file size. What moves is only who may draft the blanks, into a manifest a human still merges.The route states the only order the protocol allows: rerun, then publish. Its instruction was "apply, commit, re-run". But
apply-patcheseditsshipgate.yaml, so the moment it succeedsagents-shipgate agent controlrefuses withworkspace_changed— "the working tree carries 1 uncommitted change this decision never saw" — and the v20 refresh rule requires that read before any commit, push, or PR update. The permissions printed beside the route were computed against a manifest that no longer exists. So the instruction, and the typedexpects, now promise the write and the supersession rather than a commit, and say that the rerun is a fresh decision that may hand the branch to a person.A withheld route names itself.
report.jsonpublishes every open question withauthorable_byresolved whether or not the route is published, so an agent could see that three rows were its own to write whilecontroloffered it nothing and explained nothing — which reads as an invitation to edit the trust root without the route. The headline, and socontrol.reasonandcontrol.next_action.why, now carries for example3 declaration(s) this scan could draft are withheld: a blocker is open, and that decision is a person's.The sentence is produced by the same pass that withholds the route, so the published cause is always the cause that acted; open blockers are checked before the verdict, so a blocked report names the blocker rather than the verdict that follows from it; and only a comparison that actually ran may say the gate was weakened.It is headline context, with the standing consequence: it shares the 400-byte prose budget, it is dropped whole rather than truncated, and it yields to the gap-provenance clause, which names a subject that left the analysed surface and renders into the PR comment a person reads. A run whose verdict and worst blocker have spent the budget publishes no context at all. Do not branch on its presence — a guaranteed machine field would be new published surface and is not in this change.
Rendering and routing reachability only: no schema, verdict, permission vector or contract version moves.
-
initno longer writes a source type it guessed, and says when the block it wrote is a scaffold. (#441)detectonawslabs/mcp'ssrc/billing-cost-management-mcp-server— a FastMCP Python MCP server — reportsis_agent_project: falseand"not a Shipgate target".init, the command the control loop routes to fromverify --preview, wrote a manifest for it anyway, and the manifest declaredtype: openapifor a repository containing no OpenAPI spec.idandpathwere flagged inplaceholders[];typewas in neitherplaceholders[]nor a comment, so filling in the two flagged blanks yielded a schema-valid manifest describing a source that does not exist.Three changes, one per defect:
- The fallback
tool_sourcesblock is nowid/type/pathallCHANGE_ME, all three inplaceholders[], under a comment that lists every built-intype(rendered fromBUILTIN_TOOL_SOURCE_TYPES, so it cannot drift) and notes that a source type registered by an installed third-party adapter is equally valid —ToolSourceConfig.typeis deliberately open, and a repository only a custom adapter can read is more likely to reach this scaffold. The comment states that nothing here was inferred.pathalso loses its.yamlsuffix, which belonged to the guessed type. init --jsongainstool_surface_origin:"detected"when every source was read out of the workspace,"scaffold"when none was, andnullwhen this run's render reached neither disk nor the payload (skipped_existing,refused_unresolved_scope) — the authority ruleplaceholdersalready follows. The same fact is stated in prose inmanifest_messageand on stdout, and incontrol.reasonon the routes where init's own reason is the envelope's; on a freshly written manifest the human-owneddeclared_purposeplaceholder outranks it andcontrol.reasoncarries that declaration instead, which is whymanifest_messageis the field that always says it. The renderer decides the origin — no caller re-derives it — and the published next step for a scaffold is the edit that completes it, not ascanthat cannot resolve an adapter forCHANGE_ME. The two renderers do not say the same thing about why:--minimalnever runs framework detection, so it reports that rather than claiming discovery found nothing.- Conventional-directory discovery (
prompts/,tools/,.agents-shipgate/) now reads the whole tree, not just the workspace root. A Python distribution puts its tools under the import package —awslabs/billing_cost_management_mcp_server/tools/— and reading only the root reportedhas_tools_dir: falsefor the one structural signal that repository offers. Deduplicated by directory name, so a monorepo with thirtytools/directories contributes the one weak signal a single roottools/does; the credit can never flipis_agent_project, because every strong signal is already worth the full detection threshold on its own.workspace_signals.conventional_dirsnow carries the workspace-relative path of each one rather than its bare name — with the check reading the whole tree a name is no longer a location, and["tools"]sent a reader to a directory the reproduction does not have.SHIP-DIAG-NO-AGENT-SURFACEnames those paths instead of asserting a flat list of absences, andSHIP-DIAG-PURE-PROMPT-EXPERIMENTkeeps asking about a rootprompts/: "only prompts/ is present" describes the shape of a workspace, and reading the widened signal made it fire on a thirty-file TypeScript MCP server withsrc/prompts/. The scan walks distinct parent directories on strings rather than callingPath.relative_toper inventory entry, which took 4.4 s on a 120k-file inventory against 42 ms for the shipped form — on a whole-workspace passdetectalready runs for exactly the monorepos #363 and #395 are about.
Two defects found while fixing those:
AdapterRegistry.require('CHANGE_ME')told the reader to enable third-party adapter discovery and install a package — for a value Shipgate itself wrote. Both the message andSHIP-DIAG-UNKNOWN-ADAPTER-SOURCE-TYPEnow route the placeholder to an edit. Both prose copies of the built-in type list had also droppedcodex_configandconductor; all three copies now render from the schema's own tuple.init --minimalon a workspace with no MCP/OpenAPI sources emitted an emptyopenai_api:block and notool_sources, producing a manifest the schema rejects. The guard selecting the fallback tested the artifact dict, which has fixed keys and is therefore always truthy, so the fallback was unreachable. Both renderers now ask the same question through one shared tuple of anchor keys — which also stops--minimaldeclaring anopenai_apisurface for a bareprompts/directory, a signal an Anthropic-only project carries too.
No verdict, finding, or version moves:
report_schema_version,contract_version, the verifier artifact version, and every published schema document are unchanged. - The fallback
-
The same MCP server declared in two files is one capability, reconciled. A
codex_configrow over a workspace where two packages each carry a.mcp.jsonnaminggithubaborted the scan with'pkg_b/.mcp.json' was read twice as one tool source [...] Remove the repeated shipgate.yaml entry naming 'pkg_b/.mcp.json'— false on both counts, and naming an edit nobody could make: the manifest namespath: .once, and that file was read once.Two deliberate rules collided. An MCP capability is identified by
(server, tool)and by nothing else, so that moving a.mcp.jsonis not a capability change (mcp auditpins this); and one identity may be observed only once. Qualifying the minted id with the path resolves the collision and breaks the first rule, so it stays rejected —tests/test_mcp_manifest.py::test_the_minted_server_id_stays_free_of_the_path_it_was_read_fromkeeps it rejected. The answer is that the two files declare one capability twice, and the reader reconciles them before the catalog sees them.Identical declarations are silent. Nothing was dropped, and a source warning is a gating input; the ordinary monorepo layout gains no evidence gap. Sameness is decided on the declaration as written, not on the fields that reach a catalog row, so two files agreeing on every tool and disagreeing on the command that serves them are not identical.
Disagreeing declarations are merged conservatively, never by picking one. The merged server carries the union of the tools; a claim that raises risk survives from any one declaration (
destructiveHint, an unenumeratedserver.*remainder, secret environment names, an external URL, an authority scope,approval_mode: approve— which reaches even a tool whose own file said nothing about approval); a reassuring claim survives only when every declaration makes it (readOnlyHint, a local-documentation server, a transport); and two files describing one tool with different schemas leave the interface unknown rather than publishing either. A declaration the auth parser refuses stays refused rather than being rebuilt as a valid one. Each merged tool names the file that declares it, and each declaration that did not enter the catalog on its own becomes aSourceSurfaceOmission— anadapter_parserow in the #403 exclusion ledger, accounted for by the source warning that reports it.Two defects found alongside it:
- A
codex_configrow over any config naming an MCP server aborted the scan before reaching the above: the loader returned one file-level tool source holding tools stamped per server, so every tool was reported as belonging to a source other than the one it was read from. It returns one source per server. - Two
tool_sourcesentries reading one server from two files is the one duplicate no reader can reconcile — neither entry's declarations speak for both — and it was reported as a repeated manifest entry, which it is not. A thirddetails.cause,duplicate_across_artifacts, names both files and the two repairs that exist. Documented indocs/errors.json.
No verdict, count, or version moves:
report_schema_version,contract_versionand every published schema document are unchanged. - A
-
The report's
Root agent:line names the agent instead of hashing it. (#329) Every shipped sample printedRoot agent: agent_v1:7205d836…at the head of the Agent Binding Surface section — a derived digest that appears in no file the adopter has, telling them which agent the whole section is about in the one vocabulary they cannot look anything up in. It now readsRoot agent: durable_order_agent [conductor_workflows], resolved through the same agent label index a binding gap uses, so the section header and a finding about that agent cannot spell it two different ways.unresolvedis said only where an agent really could not be named — no root at all, or a root id no node carries; chaining back to the id would restore the digest on exactly the graphs that already read worst, andbinding_surface_facts.root_agent_idstill carries the identity inreport.jsonfor a bug report.The sweep that was supposed to catch this could not see it. The Markdown renderer escapes every value it prints, so the line reached
test_sample_markdown_speaks_the_adopters_vocabularyasagent\_v1:7205d836…— and every derived id shape and internal term incore.adopter_textcontains an underscore, which made that sweep close to vacuous over the whole half of the report that goes through_safe_markdown_text. The sweep now un-escapes each line first, against a_unescape_markdown_textdefined as the escaper's inverse beside it, with a negative control asserting the raw escaped spelling passes the matcher while the sweep rejects it.unresolvedis only ever said about an agent. Two states reach this line with a truthyroot_agent_idand no agent behind it, and reading either as an unnameable agent describes a failure that did not happen —Status: structuralandPass eligible: trueare printed around it.A report carrying no agent graph at all — the
legacy_directcompatibility assessmentcore.findings.report_builderbuilds, and the schema default for areport.jsonwritten before the field existed — now readsRoot agent: none (tools bound directly, no agent graph).And a repository that declares exactly one reviewed
tool_sources[].bindingsurface and observes no agent:_select_rootreturns that sole surface node, so the graph has a root id pointing at akind: tool_source. That printedRoot agent: github_mcpbesidePass eligible: true— announcing an agent object the repository does not have — while the two-surface form of the same repository printed the correct sentence. Whether a repository has an agent is not a function of how many sources it declared, so the answer is read from the node'skind, and both forms now saynone (graph rooted by declared tool sources).The
Entry points:line beneath it (#432) resolves through the same index, and its own fallback is nowunresolvedrather than the rawagent_v1:id. The two lines had disagreed about the same agent one line apart —Root agent: agent_v1:7205d836…aboveEntry points: durable_order_agent [conductor_workflows]— which is the drift one shared index exists to prevent. Markdown-only: report, packet and verifier schemas are unchanged, and the five samplereport.mdgoldens are regenerated. -
The loader-contract failure now offers a way forward. "That is a defect in the loader, not in this repository's configuration" was accurate and terminal: the reader cannot edit an adapter they do not own, and had just been told their own configuration was not at fault. When the dispatcher can prove which
tool_sourcesentry produced the read, the message now adds Until it is fixed, removing the tool_sources entry 'x' from shipgate.yaml lets the scan run without the tools that entry reads — after the diagnosis, and named as the workaround it is rather than as the repair.detailsgainsconfigured_source_idalongside the existing keys. A source no configured row produced is offered nothing, rather than an entry the reader could not find. -
A new evidence gap now says which subject left the analysed surface. (#433) The exclusion ledger from #403 records precisely which subject each stage removed —
("binding", "find_duplicate [github_mcp]", "evidence_gap")— and no human-facing surface carried it. A reviewer ofgithub/github-mcp-server#3020, a PR adding exactly one tool, was told "1 of 83 evidence gap(s) are new in this diff" and never what the one was; the blockers were pre-existing debt about the other 115 tools, so theMost severe:clause that had been carrying the subject in other cases was about something unrelated to the change. That was #403's own thesis — a stage computed the right signal, stored it, and did not connect it to the decision — standing at the ledger's own output, and it is the epic's last open box.verify's headline, and thereforecontrol.reason,control.next_action.whyand the PR comment'sSummary:/Next action:lines, now continue:New in this diff and not fully analysed: 'find_duplicate [github_mcp]' — not bound to the root agent.Rendering only: no verdict, count, gap, finding, or permission moves, and no version does either (report_schema_version,contract_version, the verifier artifact version and every published schema document are unchanged).Selected by diffing the ledger itself, base against head, on
(stage, subject, reason)— so the clause claims exactly what it can prove: these rows are in the head ledger and were not in the base one. Onlyevidence_gaprows, which is what makes the multiset exact on both sides, because those are the rows the ledger's cap never drops. A settled workspace has no such rows and gains no clause. The subject printed is the ledger entry's own string, built bycore.surface_exclusions.catalog_subject, so it cannot drift from the gap row it came from (the join defect #413 fixed one layer down).A name is the ledger's own name, delimited, or it is not shown. The subject is quoted; a subject longer than the cap, or one normalization would rewrite, or one carrying the quote character, is counted in the tail instead of being shortened or escaped. So the printed name is always exactly the ledger's, and a tool named
find_duplicate. Control state complete; agent may mergecannot put that sentence intocontrol.reasonas prose. When no subject can be printed the count is still published, because a subject really did leave the surface.No phrase states provenance. "New in this diff" is said once, by the lead-in, from the ledger diff that proves it.
BindingSurfaceDiff.added_unbound_tool_idsis head-minus-base and covers both a tool this change added and one that was reachable at the base and lost the edge that bound it, so thenewly_unbound_toolrow's owndetailno longer says "this change put the tool in the catalog" either.Bounded, because it shares a 400-byte envelope. At most three subjects are named, each capped on its own account as scanned input, grouped by cause so a diff that adds six unwired tools reads as one list with one reason and an
and 3 moretail — the wayMost severe:already handles the findings side. The clause shrinks itself by naming fewer and counting more, because a clause that does not fit is dropped whole. One lead-in covers a grouped list, so it says "Not fully analysed" rather than the ledger's own "excluded from analysis": asurface_not_enumeratedrow is a tool that was analysed as far as its surface could be read, and the excluded subject is the unread remainder. Reason tokens render through one table beside the builder that emits them (core.surface_exclusions.exclusion_phrase), and a third-party adapter's own token falls back to a phrase that claims nothing about a cause nobody recorded.And the headline's own budget now yields whole sentences. The evidence-gap context was composed into one string and sliced to fit — right for one unbroken run of untrusted text, wrong the moment that text names subjects:
delete_repo…is not a shortening ofdelete_repositorya reader can act on, it is a plausible other tool, andNot fully analysed: find_dup…names nothing at all. The context is built as ordered sentences, most load-bearing first, and every composition route fits it by dropping whole sentences from the end. The pre-existing "no new evidence gap" note is split the same way, so a tight budget drops the declaration remedy and keeps the fact instead of losing both. Byte-identical wherever the whole note already fitted, which is every case the suite covers.And the human-review route follows the headline rather than reproducing which of the headline's routes carries a governance requirement. That second copy of
_verifier_headline's branch conditions had drifted: on a PR that both adds an unbound tool and edits the trust root, the headline named the subject andcontrol.next_action.why,human_review.whyand the PR comment'sNext action:line did not._verifier_headlinepublishes every governance requirement as a reserved suffix, so the headline always states it, and the human-review reason can simply be the headline. -
A published tool surface is one reviewed declaration, not one row per tool. (#432) Binding an MCP server's own surface required naming every tool individually under
agent_bindings.declarations[].tools— 116 selector rows forgithub/github-mcp-serverto state a fact that is structurally true of the source, and the point at which an adopter stops. The two shorter spellings a reader reaches for both dead-ended:agent_bindings.rootnaming the source reportedambiguous_root_agentas though a better selector existed, and a"*"selector was matched as a literal tool name. Until one of them was writtenreachable_toolswas0, nothing downstream ran, and the verdict wasinsufficient_evidencewhatever the change did.tool_sources[].binding—{complete: true, reason}— states once that a source's published surface is the surface under review. It sits wheretool_sources[].authoritysits and makes the same shape of argument: binding, like authority, is a fact about the source rather than about each function it exposes. For an agent that is not true — a catalog may hold 63 OpenAPI operations of which the agent wires 5, and #385 drew that boundary deliberately — so the block is opt-in per source and changes nothing where it is not written. It is additive and widening: it can only move tools into the analysed surface, where every check then judges them.Both dead ends are routes. When nothing observed an agent object, the root gap no longer prescribes a selector that cannot exist, and a
tools:selector spelled as a pattern ({tool: "*"}) is told that selectors name one tool exactly and which statement that spelling was reaching for. It says so, routes toshipgate.yaml#tool_sources[].binding, and the declaration scaffold writes one block per configured source carrying the ids read off the surface and<REVIEW_REQUIRED>for both halves of the judgement. A catalog whose tools come from a per-scan adapter has no row to declare on, so it keeps the closed-worldagent_bindings.declarationsroute instead of being handed a remedy the schema rejects. Once a source is declared,root: {object: <id>, source_id: <id>}resolves to it.It stays a human declaration. Inferring "this source binds everything" from the source's own content is the #268 attack; the block is refused in agent-authored
tool_sourcesproposals and is a human-owned placeholder, sodoctorwill not publish an executable edit for it. A reviewed declaration that binds no tool fails closed rather than proving a graph over an empty surface, and two reviewed closed-world statements about one node — a declared surface selected as the root, plus anagent: rootdeclaration listing something else — raiseconflicting_binding_evidenceinstead of being silently unioned into a proven graph.A graph can now be rooted by something that is not an agent, and says so. A repository publishing two servers has two entry points and no root agent, which is not the same fact as "the root could not be identified".
binding_surface_facts.entry_point_agent_idsis every node the reachability walk started from — empty exactly when nothing rooted the graph, and equal to[root_agent_id]for every graph a prior release could produce — andagents[].kind(agent|tool_source) says what each node in that array is. The Markdown report stops calling the deliberate stateunresolved.Report schema
0.41 → 0.42; both fields are additive, v0.41 is frozen and hash-pinned, and every prior version is read forward. The manifest field is additive too, and a CLI that predates it rejects the key with a routableConfigError(exit 2) rather than ignoring a reviewed claim. -
A coding agent can now answer the declaration questions the scanner already knows the answers to. (#410 §D) The questionnaire (#410 increment 2) told a person which blanks were owed; it had no way to say that most of them were the scan restating what it had just read, and no way for the agent already holding the branch to write those down.
verifypublishes one new route —control.next_action.kind: "confirm_declarations"— carrying the exactapply-patchescommand that writes those answers and the question list, each row taggedauthorable_by: "coding_agent" | "human". An agent applies what it may, commits it to the branch, and stops at the rest by name instead of at "human review required". Report schema0.40 → 0.41, packet0.16 → 0.17, verifier0.13 → 0.14, runtime contract24 → 25(minimum_control_contract_versionstays21: the sharedAgentControlunion is unchanged, exactly as for the setupeditroute).Authorship is decided by content, never by who is running. A row is
authorable_by: "coding_agent"only where the scan filled every blank in itsdeclaration_template— an effect, drawn from the closedActionEffectvocabulary, never weaker than any reading it observed — and the question is not one that asks a person to look again. Every authority block, override and inventory keeps its<REVIEW_REQUIRED>blank and its"human"tag; so does adeclaration_driftrow, whose template is complete because it restates a confirmed answer beside the pin that moved (#410 §E) and whose whole purpose an agent would close by re-stamping it. The rules are enforced on the models rather than in the builders that set them, so a gap kind added later cannot inherit a licence its author never considered.What the agent gains is a pen, not a decision. The new
declare_actionpatch kind is outside the defaultapply-patches --kinds, so no existing pipeline (bootstrapincluded) starts writing manifests. The schema binds it to the row that published it: the patch is exactly thedeclaration_template, split into the keys that name the action and the fields that are written, at high confidence, from the closed effect vocabulary — a row cannot advertise an evidence-derived tag beside a patch that writes something else. It writes only into fields the manifest leaves silent; a row that already answers one differently, two equally compatible rows naming one tool, or a manifest that moved since the scan is refused outright — exit 5, nothing written, because an agent that re-ran verify after a silent no-op would loop against an unchanged file forever. Same-name tools from two providers are supported rather than refused: rows are matched on the qualifiers they actually share.requires_human_reviewstaystrueon every evidence-gap row and the route'spermissionsare publish-only (edit/commit/push/update_pr, nevermergeorreport_complete): writing a declaration touches the trust root, so a person still merges it. A weakening written by hand is not blocked at the file — it is answered at the next scan bydeclaration_below_inferred_evidence(#409).The route fires only where a declaration is what the verdict is short of:
insufficient_evidence, no blockers, no proven policy weakening, a working-tree run (a ref-bound rerun would re-scan the commit the edit is not in yet — the same precondition the mechanical repair route has always carried), and a report that actually carries the patches the command would apply. That last one is why declaration patches are emitted on every scan rather than under--suggest-patches: a route may not name a step the report it points at does not contain.next_action.patch.target_pathis relative tomanifest_dir, unlike the absolutetarget_filethe pointer patches carry. The row is embedded by the evidence packet, the SARIF file and a cached base scan, all of which travel; averify --baserun scans an archived checkout, so the absolute form named a temporary directory that no longer existed when anyone read the artifact and changed on every run, moving digests that are supposed to be reproducible. Relative removes the class instead of asking each consumer to strip it, and makes containment structural —apply-patchesresolves it undermanifest_dirand can no longer be handed a path that escapes. Base evidence additionally drops the patch outright (a base report describes a commit nobody is editing), andBASE_CACHE_KEY_EPOCHmoves to4so no cached entry written mid-flight is served back.The shipped agent instructions carry the exception, because without it they said the opposite:
AGENTS.mdand all four copies offix-top-finding.mdtold an agent that every declaration row is a human's and that no evidence-gap row ever reachesfix_task.actor == "coding_agent". They now state the one narrow case and keep every prohibition around it, and a test pins the three shipped copies byte-identical to each other.The control envelope's published size budget is re-derived,
4096 → 6144bytes: a route carrying a list is a third variable component the old number was never derived against, and a question row measures about 0.4 KiB. The list is capped at six rows — more than the measured worst case produces, since per-source authority folding turns a 117-tool repository into one question — and the printed prefix leads with the human-owned rows, because a drafted row is answered by the command whether or not it is printed while a human-owned row is what the agent has to hand a person.Three fixes fell out of building it.
VerifierArtifactdemanded that anagent_action_requiredrepair route's command equal the fix task's rerun command — which forbade the one thing such a route exists to publish, and whichtest_control_next_action_follows_agent_safe_fix_taskhad always asserted the opposite of. No verify run had reached the mechanical route and built an artifact from it, so nothing caught the contradiction; the invariant now says what it always meant, that a route may not name a command the fix task does not authorize.apply-patcheshashes the bytes on disk rather than decoded text, because reading as text normalizes CRLF to LF and an untouched CRLF manifest therefore reported drift for ever, and it re-emits the newline style it found. And it pins YAML sequence indentation to the styleinitwrites, because round-tripping re-indented every unrelated list in the manifest and buried a one-line declaration in whitespace. -
Control packs: the rules layer, chosen once at
init. (#410 §F) The rule that a financial write needs approval, an audit log, and idempotency was written four times in the engine, was not selectable, was not named, and was stated nowhere the adopter reads — so seven findings said "lacks a declared approval policy" and none of them said what requires one.policies.control_packnow names the effect → required-controls rule set for the whole repository:default(today's rules exactly),financial-strict, orread-only-agent.shipgate init --control-pack <id>writes the selection with a glossary of the alternatives above it, andinit --jsonreports the choice and every answer it takes undercontrol_pack. Omitting the key meansdefault, so every existing manifest keeps its verdict — the sample goldens' findings, severities,blocks_release, and release decisions are unchanged.Selecting a pack can only tighten the gate. Every built-in pack requires at least what
defaultrequires — asserted at import, and pinned by a real scan across every (pack × effect) pair plus a whole-finding-set comparison, because a table read by eye is not the property that matters. A pack also decides only which control findings fire: the obligation lattice that says whether a declared effect covers an inferred one stays the built-in one, so a pack requiring identical controls for two effects still cannot let a declaration of one discharge the other (#413).The engine now reads that one table rather than mirroring it. The three dedicated action-control branches, the high-impact approval rule, and the tool-level
SHIP-POLICY-APPROVAL-MISSING/SHIP-POLICY-CONFIRMATION-MISSINGeffect sets were four hand-maintained copies of the same rows; they are one now. Effects with no control check of their own report a pack obligation throughSHIP-ACTION-POLICY-VIOLATIONathigh, naming the rule inevidence.policy_idascontrol-pack:<pack>:<effect>— and, like the four dedicated families, achecks.ignoreentry records the exception without waiving the blocker.scanstdout andreport.mdnow name the rule that wanted each missing control, once per rule instead of once per tool: "financial write requires approval.required, safeguards.audit_log, and safeguards.idempotency — 3 actions short". Both render one projection, so they cannot report different counts from one report.SHIP-ACTION-POLICY-VIOLATION's built-in title says "without required controls" rather than "without approval", which under a stricter pack was telling an action that has approval that it does not.A pack move is a policy weakening, and the gate says so. Every field in the effective-policy snapshot answers does the same finding still block?; a control pack answers does the same action still produce the finding?, which is the other way a gate gets weaker — and the one a base-vs-head comparison could not see.
effective_policy.control_pack(report schema0.39 → 0.40, additive, v0.39 frozen) publishes the pack in force, andSHIP-VERIFY-POLICY-WEAKENEDgainskind: control_pack_weakened: one finding per pack move carryingremoved_controls[] = {effect, controls}. A base snapshot with no pack predates the field and is compared asdefault, so the "no pack is weaker than default" invariant is enforced by the comparison rather than assumed by it. A snapshot naming a pack the build cannot resolve is the other case and is not read as "no weakening": it routes toSHIP-VERIFY-POLICY-BASE-ABSENTwithkind: control_pack_unrecognized, the reason code that says the comparison could not be made.A finding says which rules it is about, and identity follows. Every control finding stamps
evidence.control_packandevidence.control_effects— the effects the rule actually matched, not its whole category, because one id serves both high-impact effects and recovering them from it reported a code-execution action as also operating on production. Both are excluded from the fingerprint, so baselines recorded before the fields keep matching.run_idnow hashes the pack's id, version, and canonical obligations: two manifests enforcing different policy are different runs even where neither produces a control finding. The pack-onlypolicy_idiscontrol-pack:<effects>with no pack name — it is a fingerprint input, and two packs asking the same thing state one rule, so naming the pack would re-open a baseline entry on a move that changed nothing. That prefix is reserved:action_surface.policies[].idrejects it at manifest load, and non-waivability is decided fromevidence.control_packrather than from the id string, so a user policy cannot inherit the treatment.No new check ids and no new CLI command.
init's recovery routes repeat the selected pack, so following an emittednext_actioncompletes the setup that was asked for. Switching to a stricter pack re-opens baseline entries for control findings whose requirements grew — an acceptance recorded under looser rules should not carry into tighter ones — while a move between packs that require the same controls re-opens nothing. -
Human-facing findings are grouped by subject, and a recommendation names only what is missing. (#364) A scan of four money-moving tools produced seventeen findings across five check families. The summary showed three of them, and all three were the same check on sibling tools — so scopes, idempotency, owners and guardrails were never mentioned at all. Severity is not the axis a reader acts along: they open one tool, fix what is wrong with it, and move on. The subject is now the group key on all three human surfaces (
scanstdout,report.md, the PR comment), with severity and blocking status as attributes of each row, a location hoisted to the heading when every row shares one, and every truncation stating what it hid.Separately, the finding a reader would open first told them to declare a control the same finding's
evidence.missingsays they had already declared: the sentence was a per-check literal naming every control the effect obliges, while the evidence named the subset actually absent. Following it costs a round and returns the reader to the same finding. The three built-in control checks now build the evidence, the sentence, and the predicate row from onemissinglist at one call site, so they agree by construction rather than by review;SHIP-ACTION-POLICY-VIOLATIONstops naming both high-impact effects on an action that has one. Where nothing is declared the sentence is unchanged, which is why no shipped sample'srecommendationmoved.Presentation only.
findings[], fingerprints, counts, severities,blocks_release, SARIF, and the release decision are untouched —report.jsonstays the flat per-finding record automation consumes, and the samplereport.jsongoldens are byte-identical.Both PR-comment styles carry the block.
capability-reviewis the default andfindingsis the legacy style being retired, so wiring it only into the latter would have shipped the change to nobody — the comment a reviewer actually gets said what moved and nothing about what is wrong per tool.Three rules keep a row honest about what it is saying. A row only renders
missing: …when the check wrote that list as plain strings; the action-policy checks write{"path", "expected"}rows for both an absent path and a present-but-wrong value, so flattening them saidmissing: safeguards.dry_runabout an action that declaresdry_runand collapsed two policies requiring one path into one indistinguishable row — those keep their own title and their adopter-authored recommendation. A location falls back tolocationbeforeref, because most adapters populateref="agent.py"+location="agent.py:5"and leavepathunset; without it four findings on four functions rendered one suffix and then shared it. And a path is escaped, never trimmed, so a filename with a leading space stays that filename.A finding carrying a tool name and no id —
SHIP-BASELINE-INTEGRITY-*, copied from a historical baseline entry — is not resolved through the current catalog. Uniqueness today cannot establish identity then, and the missing[provider]qualifier is the signal that the two are not known to be the same tool.Two joins had to get stricter to make the grouping honest. A group blocks when the release decision names one of its findings as a blocker, not when a finding carries
blocks_release— a baseline separates those, filing accepted debt as a review item while the flag stays true. And a finding is matched to a decision item by id, then by fingerprint for an item with no id, then by check id and title for an item with neither, each tier holding only what the tier above could not: two findings can share a fingerprint, andsamples/conductor_agentships two that share a check id and a title. Within a group, blocking rows sort ahead of severity: a subject whose only blocker sorted last by check id showed BLOCKS RELEASE above three rows that do not block, with the one that does hidden under "and 2 more findings". -
The questionnaire asks the unread questions first. The declaration questionnaire promised an order — "by how much answering can move the verdict" — and delivered the opposite of it. It ranked each question by the effect the scan had already inferred for the action, and a pre-filled proposal is offered on exactly the same condition, so the two mechanisms ran off one signal: every question that arrived with a draft answer outranked every question that arrived blank. On the fifth
adk-samples#1745walk that put three already-drafted mail tools at Q2–Q4 and the financial write — the single question that produces bothcriticalblockers once answered — at Q6, behind three drafts a reader had to confirm first (#419).Rank by the ceiling, not by the floor. Observed risk and "how much can answering this move the verdict" are not the same quantity, and the header claimed the second. An action nothing has bounded is not a low-risk action; it is an unmeasured one, its answer can still turn out to be
destructive, and it is exactly where a human answer carries new information. A question about an unbounded action now sorts above every bounded one, and the bounded ones keep their old order among themselves — strongest first. On the same walk the financial write moves from Q6 to Q3 and all three drafts move to the end.Bounded is not the same test as draftable. A reviewed declaration and policy-eligible source evidence bound an action even when what they establish is
read, and the rule that decides whether to pre-fill a value cannot say so: it refuses to drafteffect: readfrom anything, because a confirmed guess ofreadis the one direction that loses safety. Ranking on that rule would send an OpenAPIGETnameddelete_accountto the top of the file with its name breaking the tie — the same defect inverted — so ordering asks its own question. A heuristic reading ofreadstill bounds nothing: this resolver may not act on it, so the answer remains open.And a name breaks the tie among blanks. Where nothing was observed there is nothing to rank by, so the questionnaire falls back to the shape of the action's name — mutating, neutral, retrieving — using the keyword vocabulary the scanner already owns. This needs no trust and is given none: it is consulted only among actions the scan measured nothing about, it cannot reorder an action the scan did read, and it never reaches a claim, an issue, or a verdict. Getting it wrong costs a reader one place in a list they have to finish either way.
The header sentence now states the order the file actually uses — including the heuristic-read case, where a block prints a reading and is still unbounded — and a test renders the file and checks the two against each other. A blank with no reading at all now says so at the block, since the header explains that the top of the file is the unbounded half and silence read as "nothing to see here".
Published contract.
report.json/semantic_coverage.declaration_questions.open_questions[]documented itself as "highest-acting action first", which is no longer what it is. The model docstring is emitted verbatim into the report, packet, and verifier schemas, so it anddocs/agent-contract-current.mdnow describe the ranking above and say plainly that position is not severity: the action at the top is the one least is known about. Field shapes are unchanged. -
A confirmed declaration is pinned to the evidence behind it. Declarations matched by name and nothing ever re-opened one, so a green gate at month twelve could rest on a description of a function that no longer does what it did.
action_surface.actions[].basisrecords which evidence an effect answer was given against, asconfirmed:<digest>; every scan re-derives it and compares. Equal is complete silence. Different re-opens the question as adeclaration_driftevidence gap that names what the action reads as now, hands over the new pin, and is closed by re-reading and re-confirming.The digest is what the questionnaire already showed the reviewer — the effects the scan observed — so "every answer is pinned to the evidence that justified it" is literal rather than approximate. It deliberately does not digest the producers: a second heuristic reading an effect somebody already answered is not new information about the action, and digesting it would let a shipgate release re-open every pinned declaration on every adopter at once. It is also stable across the arrival of the answer itself, which is the one property that would otherwise make pinning worse than not pinning: confirm a proposal, paste the
basisline the scaffold stamped, rescan, and nothing is raised.Additive and unpinned-by-default: every manifest written before this field existed behaves exactly as it did.
suggested-declarations.yamlstamps the pin on every effect answer it offers, so new answers arrive pinned; to pin an existing declaration, write any short placeholder (basis: confirmed:0) and rescan — the drift row names the value. A pin is a fact about the scan rather than a judgement, so it may be pre-filled and can never make an action pass-eligible on its own.declaration_driftis a different statement fromdeclaration_below_inferred_evidence, and a change that adds a stronger reading raises both: one asks whether the declaration is weaker than today's evidence, the other whether today's evidence is what was answered at all. Each is closed by a different edit. -
environment.target: template, for a repository that ships to be copied. A sample has no deployment, so it has no credentials, and asking each of its actions which credential it runs with asks a question the repository cannot answer in principle — both repositories the adoption walks used were of exactly this kind, andauthority: {mode: none}written twelve times is the same claim spelled at cost. Declaringtemplateanswers the authority dimension once, for every action that does not say otherwise.A default, never an override, and it applies only where nothing else answers the question: an action row's
authority, atool_sources[].authorityblock, an action's ownscopes:list, and anything the source publishes all win over it. That last one is the whole safety argument — the reviewed record supplies mode, auth type, credential mode, and permission list together, so applying it over a source that published any of the four erases what that source proved. And never silent: every action it answers for is one semantic review concern, so a template repository can reachreview_requiredand neverpassed. Stating real authority stays the price of a green gate. -
SHIP-TRUST-MANIFEST-UNPROTECTEDreads the file GitHub would read. Protection is credited only from a CODEOWNERS that covers the manifest and covers itself — a rule set owningshipgate.yamlbut notCODEOWNERSdescribes a protection one edit deep — and it fails closed everywhere the forge would not honour the rule: outside a git checkout, on a file of 3 MB or more (which GitHub ignores entirely, with no fallback to a lower-precedence location), and on tokens GitHub does not accept as owners.docs/*no longer matches further-nested files, which GitHub documents and gitignore does not. The three CODEOWNERS locations are now trust roots themselves, so a change to the file that decides protection is classified as one. -
SHIP-TRUST-MANIFEST-UNPROTECTED— who may change the gate. Every verdict rests on the manifest, so a repository that lets it change unreviewed has a gate the gated work can turn off. Attestation is the PR review of a protected file rather than a separate ceremony, and CODEOWNERS is the half of that a checkout can prove. The finding fires only where the manifest is load-bearing — the manifest's ownci.mode: strict— and it never moves a verdict:low, never a review item, because branch protection lives in repository settings no file here can read and deciding on the visible half alone would be the pretending it exists to avoid. -
doctorsays which rung of the adoption ladder you are on. Every intermediate state of an adoption reads like a failure: a manifest with no declarations reportsinsufficient_evidence, which is accurate and sounds broken.doctornow names the rung — 0 Audit, 1 Gate the delta, 2 Answer on touch, 3 Strict — says what it is worth on its own, and names only the conditions that are actually unmet to reach the next one. Published ondoctor --jsonasadoption. A workspace with no manifest is told it is on rung 0 rather than only that a file is missing, with the exactinit --workspace … --writeinvocation that leaves it.A rung describes what a repository has declared, and says so. It does not predict a verdict — a fully structural surface owes no declaration questions and can pass from rung 1 — and it does not claim enforcement.
ci.mode: strictis the manifest's own statement, while the workflow that runs Shipgate passes its ownci_mode(the generated one shipsadvisory), and branch protection is a repository setting no file in a checkout can read; rung 3 names both limits. Nor is a manifest a workflow:initinstalls one only with--ci, so rung 1 describes a gate that can run here rather than one running on every pull request, and points at--cifor that. The rungs are also not a cumulative chain: a repository whose surface resolves structurally may never be asked a declaration question, and would otherwise be stranded at rung 1 forever. -
A pin re-opens when authoritative evidence is replaced, not only when a reading appears. The digest covered the effects a scan observed but not their strength, so a tool published with
readOnlyHint: truebeside aread_onlykeyword hint kept the same pin after the annotation was deleted: it still readread, from the heuristic alone.readis the worst classification to lose it on — a heuristic may never establish read-only (#357) — so a safety-sensitive answer survived on evidence that could not have produced it. The digest now covers(reading, strongest evidence class), which keeps corroboration quiet (a second heuristic agreeing with an annotation changes nothing) while a replacement moves the pin.evidence_gaps[].next_action.observed_readings[].policy_eligiblepublishes that half, so a consumer can reproduce the pin from the row it is printed on, and the questionnaire marks a heuristic-only reading as such. -
Existing
capabilities.lock.jsonfiles keep loading. The lock schema bump froze0.7without teaching the reader about it, so every committed v0.7 lock started failing to load — v0.7 had been readable only because it was the current constant, and nothing moved it into the compatibility set when the next bump took that constant. It is there now, with its historical capability standard pinned literally rather than defaulting to the current one. The guard walks the publisheddocs/capability-lock-schema.v*.jsonset instead of a hardcoded version, so the next bump cannot repeat it: the old test pinned0.6and kept passing throughout. -
A merged declaration block reads in manifest field order. Two evidence-gap rows about one action are merged into one block to paste, and the merge kept whichever order the rows arrived in — a drift row folded into a below-evidence row put
basisin the middle and the declaredeffectunderneath it. Blocks now render in the order the manifest itself uses, taken from the model rather than restated.Report schema 0.38 → 0.39, packet 0.15 → 0.16, verifier 0.12 → 0.13, capability lock 0.7 → 0.8, capability-lock diff 0.8 → 0.9; all additive, all prior versions frozen, hash-pinned, and read forward.
-
One action, one permission list, with no reviewed authority either. A manifest row that listed
scopes:and declared noauthority:block at either site turnedverify --baseintoInternal error(exit 4) on a legal manifest. The action's permissions were spelled twice and the two spellings disagreed: the action lens took the row's list when it had one and the source's auth scopes otherwise, while the authority dimension took the row's list only where a reviewed record existed.CapabilityFactV1requires the two to project one list, so where they disagreed, rebuilding a capability fact from a serializedActionFactraised — and that rebuild happens on exactly one route, the MCP capability comparison against a base scan. A plainscannever reaches it, which is why no sample and no scan-level test saw this.Declaring authority once per source closed the reviewed half of that by normalizing both sites into one record. This closes the rest: one resolver (
core.semantic_assessment.resolve_action_scopes) decides an action's permission list, read by the action lens and the authority dimension alike, rather than teaching the capability builder to paper over a disagreement it would then have to keep tolerating. Because the shared rule is the one the lens already applied, no shape that resolves today resolves differently — every list that moves belongs to a shape that was exit 4. A sweep over source authority × declaration row now pins it, and the parity assertion added with the source-level block covers the bare-scopesshape it had to leave out.And a row cannot quietly shrink a grant. Publishing the row's list on the authority dimension is also what would let
scopes: [crm.read]erase acrm.writegrant the source proves, with astructuralstatus and nothing raised. The subset rule a reviewed authority is held to now reads the resolved list, so a barescopes:list is held to it too and reportsconflicting_authority_evidenceagainstactions[].scopes. Adding the dropped scope back closes it. Broadening is still a broadening, visible to the broad-scope policies as before.Two consequences for a manifest that used the broken shape, and only for those.
SHIP-AUTH-SCOPE-COVERAGE-MISSINGnow sees the declared list, so a row declaringscopes: [crm.read]against a manifest whosepermissions.scopesdoes not cover it raises the review item it always should have — the divergence, not the check, was what kept it quiet. Andauthority_hashmoves with the scopes, in a capability lock, anaction_surface.actionsrow, or both. -
Authority follows credentials, not functions: declare it once per source. Every action a tool source contributes normally runs with the same credential, and asking for it once per action asks the same infrastructure question N times. That is not merely tedious — it is what breeds the copy-paste that breeds wrong answers, and a wrong authority declaration is the one that makes an unscoped production credential read as
mode: none. Increment 3 of the evidence-first declaration RFC (#410) moves the claim to where the fact lives.A new
tool_sources[].authorityblock.{mode, auth_type, credential_mode, scopes, reason}, with exactly the mode co-requirements an action row already obeys — they are now one shared rule rather than two copies, so a manifest one site rejects and the other accepts is not reachable. The only difference is wherescopeslives: an action row keeps its permission list in the siblingactions[].scopesfield so there is one canonical list per action, and a source, having no such sibling, carries its scopes inside the block. Whichever site is operative supplies the whole record, permission list included, and that one list is what every surface reports and judges: the action'srequired_scopes, the authority dimension'sscopes, and the capability fact's — which the capability standard requires to agree — and the list the effect evidence reads, so a write-verb permission the manifest says an action requires still bounds that action's effect whichever site asserted it.Additive, and never a weaker statement. An
action_surface.actions[]row that declares its ownauthoritystill wins for that action; a source with noauthorityblock resolves exactly as before. The resolver normalizes both spellings into one record before it judges anything, so the source block is held to the same rule as the action row: it may resolve missing metadata and may broaden a scope set, but declaringmode: noneacross a source whose actions publish an OAuth scope raisesconflicting_authority_evidenceon each action that disagrees — naming the block to correct — and it still cannot stand in for authority a source publishes ambiguously.One blank is one question. The declaration questionnaire's unit was
(action, dimension), which counted one edit as N things to do: a source of 117 actions with no authority evidence read as 117 questions. A question is now identified by the manifest block that answers it, published asopen_questions[].answer_path, so those 117 are one question, one numbered block insuggested-declarations.yaml, and oneevidence_gaps[]row —subject_kind: tool_source, subjectcrm [tool_source], and awhythat says how many actions are waiting on it. Nothing above the published rows changed: every action still carries the issue and still fails pass eligibility for it. Conflicts stay per action, because each one asks a reader of that action which of the two claims is wrong.Nothing is prescribed where nothing can be written. The source route is offered only for a
source_idthe manifest actually configures. A per-scan adapter stamps a source id thattool_sourcesdoes not accept, and those actions keep being asked on their own row rather than being sent to a manifest key the schema rejects.Measured on a synthetic 117-tool MCP source, the shape the RFC names: 234 declaration questions become 118, of which exactly one is the authority question, and
suggested-declarations.yamlcarries exactly onetool_sourcesblock. The 117 identical authority gap rows become one, reading "117 actions from tool source 'github' have no explicit or structural authority evidence."A source id is not a foreign key. Review of this change found the join itself wrong.
Tool.source_idis minted by the adapter, and configured ids share that namespace: atool_sourcesrow of typemcpcalling itselfopenai_apihad its reviewed authority applied to the OpenAI API surface — clearingmissing_authority_evidencefor actions nobody declared anything about — while acodex_configrow, whose adapter emits ids derived from the file it read, applied to nothing at all. The dispatcher now records which configured entry each loaded result was produced for, identity resolution carries it onto the canonical action, and the declaration joins on that. A reviewedtool_identitybinding that merges observations from two configured sources answers for neither: their credentials are separate facts, and the question stays on the action row.An omitted optional field is not a claim of absence.
credential_modeis optional, and a declaration that leaves it out was overwriting a publishedservice_accountwith nothing — leaving the dimensiondeclaredand pass-eligible while capability policies matchingcredential_modes: [service_account]silently stopped matching. The published value is preserved where the declaration states none; a different stated value is still a conflict.mode: nonemeans no credential, including its mode. Both declaration sites accepted{mode: none, credential_mode: service_account}— a fact about a credential the same block says does not exist, which on a structurally complete read action was pass-eligible. Both now reject it.A version bump moves the labels, not only the filenames. The public schema-version statements had drifted: table cells reading
0.37beside a v0.38 link, "The packet schema is0.14" above a v0.15 link, and averifier_schema_version: "0.7"in README and the Claude Code skill that had been stale for several releases. Two parity tests now hold them together — a line that links the current schema must also name its version, and a quoted<kind>_schema_versionmust equal what the engine emits unless the line or its section marks it as history.Report schema
0.37 → 0.38addssubject_kindtoevidence_gaps[]andsubject_kind/answer_pathtodeclaration_questions.open_questions[]. Packet0.14 → 0.15and verifier0.11 → 0.12follow because they embed the same rows; the prior versions keep their published bytes and are read forward, defaulting to the action-scoped reading, which is exactly what those builds could produce. -
Ask only what the scanner cannot prove, and say how much is left. Adoption stalls at a wall of blanks: the fourth
adk-samples#1745walk faced0/12pass-eligible actions and a report that described the work as "24 semantic evidence gaps" — a symptom count with no order and no finish line, while the same report already held a derivedfinancial_writereading for the tool that mattered. Increment 2 of the evidence-first declaration RFC (#410) turns that surface into a questionnaire.Effects the scan observed are pre-filled.
suggested-declarations.yamlnow prints the readings behind each effect question — the distinct effects the evidence supports, each with the producers that support it — and, where they support one conservative answer, offers that answer in theeffect:line instead of a<REVIEW_REQUIRED>blank. A proposal is not an assertion: nothing consumes the file, the value comes from the closedActionEffectvocabulary rather than from source content, and it is never weaker than any reading, so confirming one without thinking can only over-declare — the safe direction, and one the monotone rule already keeps visible. Nothing is proposed from an absence: an unannotated MCP tool's protocol default, and a heuristic reading ofread, both keep the blank, because pre-filling either would let the scanner establish what only a human may (#357, #268).The file is numbered and counted. Blocks carry
Question 3 of 5banners ordered by how much answering them can move the verdict — two answers were enough to reach one on the walk, and the entry above says which two the order now leads with — and both the file header and the CLI printDeclaration questions: 1 of 2 answered; 1 open (1 authority).from one rendering, so they cannot describe the same state two ways. An open question with no blank to fill (a conflict whose repair is in the source) is still numbered and still shown, so the numbering never skips.A question is not the same thing as a declaration. The denominator counts only what both halves can be measured on — the
effectandauthorityof oneaction_surface.actionsrow — andansweredis exact: it counts dimensions that gap when the same action is re-resolved without its declaration. An action whose effect an OpenAPI method or an MCP annotation established was never asked and never appears intotal, so a repository cannot improve its progress by restating what the scan already knew.A manifest cannot be the source that contradicts itself. Found by applying the proposal exhaustively across structural evidence: declaring
risk_tags: [code_execution]on a tool whose server publishedreadOnlyHint: truewas reported as "high-confidence read and side-effect evidence conflict" attributed totool_source— but the side-effect half was the reviewer's own line. The read/side-effect conflict is a disagreement between sources, so it now excludes the manifest's ownrisk_tags,scopes, and acknowledgedoverride(the setDECLARATION_CLAIM_SOURCESalready names). This also repairs therisk_tagsrepair thatdeclaration_below_inferred_evidencepublishes, which could not close the row it was printed on whenever the action carried a read-only annotation. Two sources disagreeing is still a conflict, and nothing that gated before stops gating.Do not ask a question a declaration cannot close. Review of this change found
partial_authority_evidencecounted as a declaration question while the resolver preserves it whenever the source's authority evidence is ambiguous or incomplete — "reviewed authority cannot replace ambiguous or incomplete source authority alternatives", a deliberate safety property. An MCP tool published with scopes and no auth type asked one authority question, and writing the exact scoped block the scaffold requested left the counter at0 of 1 answeredforever. It is now routed toprovide_sourcewith no declaration template and an instruction naming the source shapes that close it. The narrower case of the same defect goes too:conflicting_effect_evidenceis raised about either surface, and only the branch the resolver attributes toaction_surface_declarationis a question a declaration answers — a server publishing bothreadOnlyHint: trueanddestructiveHint: truecontradicts itself, and no declaration touches that. Every kind that remains is now pinned by a round-trip test: raise it, apply the answer, re-resolve, require the question answered.A row that says the manifest cannot fix it does not point at the manifest.
next_action.pathis the machine-readable target coding agents and the short-formFix at …line consume, and it fell through toshipgate.yaml#action_surface.actions[...]for every kind — including the two whose repair is in the tool's own published evidence. Those now point at the source artifact (tools.json#/tools/0), or at nothing when no openable reference exists; they stay addressable through their rerun command either way.conflicting_effect_evidenceraised against a self-contradicting source also stops publishing the effect vocabulary and the "add a conservative reviewed action declaration" instruction, because adding one leaves the identical row. One predicate,is_declaration_answerable, now decides both what the questionnaire counts and what the row publishes — counting a row the repair cannot close and publishing a repair for a row the counter knows is unanswerable are the same defect from two ends.A reviewed
risk_overridestag is the manifest speaking. The source read/side-effect conflict excluded theaction_surface.actionsrow but not its sibling manifest surface:risk_overrides.tagsreaches the effect dimension asrisk_hint:manualwith basisreviewed_declaration. A reviewedcode_executiontag on a tool published withreadOnlyHint: truewas reported as the source contradicting itself, and declaring the matching effect and risk tag could not clear it. Manifest ownership is now decided by both routes — the declaration claim sources and thereviewed_declarationbasis, which in this dimension no tool-published content can carry.Reading an old packet no longer rewrites what it decided. The legacy upgrade path gated its
passed → insufficient_evidencedowngrade on "is this a version I recognise" rather than on "is this before v0.8", so every packet-schema bump quietly added the immediately previous version to the set being rewritten — a stored v0.12passedpacket already loaded asinsufficient_evidencebefore this release, explained by a claim about history that is false of it. The downgrade is now scoped to v0.1–v0.7, the versions that genuinely predate evidence-backed semantic coverage; v0.7 still downgrades and two sources disagreeing is still a conflict.Report schema
0.36 → 0.37addsrelease_decision.evidence_coverage.semantic_coverage.declaration_questions({total, answered, open, open_by_dimension, open_questions[]}) andevidence_gaps[].next_action.observed_readings[]. Both are additive and neither gates. Packet0.13 → 0.14and verifier0.10 → 0.11follow because they embed the same block; the prior versions keep their published bytes and are read forward, with an absent counter reported as0 of 0rather than as a claim that nothing was owed. The safety-qualification gate'srequired_report_schema_versionmoves with them — it compares for exact equality, so a gate left behind a bump rejects every receipt for a reason that has nothing to do with safety, and it is now pinned equal to what the engine emits by a test. -
Adopter-facing output stops naming the internal identity model. Running the tool on your own repository for the first time could produce
Duplicate tool observation identity: source_type='google_adk_function', source_id='google_adk:agent.py', native_locator='agent.py#map_account'— three internal concepts, none of them in the manifest you wrote, and the one recoverable fact (a file was listed twice) unstated (#329, invariant 5 of #327).Every string whose purpose is to tell a person what to do next — console output, the agent-mode
message/next_action/next_actions[],agent-handoff.jsonprose,fix_task.instructions[], PR comment text — now names a file, a symbol, an agent, or a manifest key. That message readsTool 'map_account' was read twice from 'agent.py' as one tool source. Check shipgate.yaml for an entry naming 'agent.py' more than once…, and the identity triple moves to a newdetailsobject on the error envelope, where a machine consumer or a bug report can still read it.report.jsonevidence blocks and the tool catalog are untouched: they are the identity model, and they are supposed to be precise.A digest was the subject of a shipped verdict. A binding gap whose issue named no tool fell back to the derived agent id, so
samples/conductor_agentshippedInsufficient evidence: the agent's tool binding graph is incomplete (agent_v1:7205d836…)as the sentence under its verdict. It now reads(durable_order_agent [conductor_workflows]). The report's conservation invariant — which already refused a raw tool id in a gap subject — now refuses any derived id, matched by shape: a guard scoped to one kind of identifier passes vacuously for every other one.verifyandscandisagreed about the same failure. Each caughtInputParseErrorand wrote its own recovery, so a failure with a precise route on one command got generic advice on the other. One resolver now servesscan,verify, and the verifier assembly path — and it names the manifest the run actually read, sincescan --workspace <repo>can discover a sole nestedservices/billing/shipgate.yamlwhile the emittededitaction saidshipgate.yaml, a different file in the caller's working directory.One failure, two repairs. A tool read twice is either a repeated manifest entry or a duplicate definition inside the artifact, and the structured action carries one path — so naming both let a consumer delete a source declaration when the file was the problem. The check now reports which cause it saw in
details.causeand the action follows it, reading the answer from the manifest because the loaders that aggregate their artifacts cannot say which one they read twice.tool_sources[].idis also stripped and refused when blank, in the published JSON Schema as well as at runtime: it is the keytool_inventories[]andtool_identity.bindings[]join on, both of which were already stripped, soid: " orders "matched neither and silently completed nothing.next_actions[].pathis the one field a caller opens verbatim, so it is the one field that is always resolved. The manifest aneditaction names is the one the run read — through--workspacediscovery, through a defaulted--configonverifyandverification prepare, and through an archivedverify --base/--head, where it used to name a temporary file that had already been deleted. Two things follow: a declared artifact is never published as a path, because it has no single base, and a failure evaluated against a ref that is not checked out publishes none either — the action names the commit instead of a working-tree file that may already hold the fix.ArtifactPathConfig.pathis also canonicalized, so declaring bothtools.jsonand./tools.jsonno longer reads one file twice and produces two canonical tools.tests/test_adopter_vocabulary.pyis the guard: it enumerates the adopter-facing strings four ways — every evidence-gap kind through the real renderers, every published message builder, every hand-written string at an emit site in the modules that produce this output, and the shipped sample artifacts — and fails on reintroduced internal vocabulary. -
A declaration cannot discharge a category it does not cover, and a published schema keeps its bytes. Three follow-ups to the monotone declaration rule, each a defect that shipped with it (#409, #411).
Effects are risk-ordered; their obligations are not. The monotone comparison read the effect rank alone, so declaring
financial_writeover an inferredexternal_communicationread as escalation and stayed silent —financial_writerequires approval, audit, and idempotency but not confirmation, which is exactly what communicating outward requires. The action reported pass-eligible with no gap and noSHIP-ACTION-EXTERNAL-COMMUNICATION-AUDIT-MISSINGwhile its external-write risk tag sat untouched in the same report. A declaration now accounts for an observation only when it ranks at or above it and obliges at least that observation's built-in controls, from the newBUILTIN_EFFECT_OBLIGATIONStable — pinned to the branches it mirrors by a test that walks each entry through a real scan. Coverage also reads every policy-eligible claim rather than theeffectfield alone, so arisk_tags: [financial_action]entry accounts for an inferredfinancial_writeexactly as the control evaluator already treats it.The two comparators disagreed. The declaration rule compared
_EFFECT_RANKwhile_non_authoritative_effect_escalation_supportcomparedACTION_EFFECT_RANK, and the two orderwriteandprivileged_data_accessoppositely — so a declaration could read as covered in one place and raisemixed_policy_evidencein the other, a verdict no override could reach. Both now calldeclaration_covers, which requires both orders to agree; nothing that gated before stops gating.Four published schema documents were mutated in place. The new
declaration_below_inferred_evidencevalue was written intopacket-schema.v0.12.json,verifier-schema.v0.9.json,capability-lock-schema.v0.6.json, andcapability-lock-diff-schema.v0.7.jsonwhile they kept their version identifiers — and the two capability-lock documents had no successor version at all, so a consumer pinned to any of the four rejected artifacts that document is supposed to describe.generate_schemas.py --checkcannot catch this: it proves committed == generated, never that a content change moved the version. All four are restored byte-for-byte, the capability lock advances0.6→0.7and its diff0.7→0.8, and a lock written under0.6is advanced on read rather than rejected — the normalizer handled only0.1–0.4, so the bump would otherwise have orphaned every committedcapabilities.lock.json.The published repair now closes the row it is printed on. The instruction named the strongest uncovered observation, so with both a
financial_writeand anexternal_communicationreading a reviewer could apply the exact edit the row asked for and get the same row back — and it fell through to "declare thewritecontrols" for an effect that obliges none. A raise is advertised only when one observed effect covers every uncovered observation and the value already declared; otherwise the row publishes therisk_tagsroute, which both accounts for the observation and makes that category's built-in controls apply.accepted_valuesand the scaffold template follow the route, so the structured action and the prose describe the same repair. An exhaustive test applies the published repair to every gapped declared/observed combination and asserts the row is gone.Every suppressed observation reaches the reviewer. An override recorded two
overridden_claim_idsbut projected oneinferred_effect, so the second observation it waived vanished fromacknowledged_overrides, the PR projection, and the packet the moment it was acknowledged. There is now one reviewer row per suppressed observation, each with its own sources. Un-acknowledged rows already named all of them. -
A declaration weaker than the evidence inferred for it is no longer silent. Declaring
effect: readon a tool this scanner itself taggedexternal_writewas accepted with zero findings: the pre-existinginferred_effect_onlygap was closed by the very declaration that contradicted the heuristic which raised it, the action went pass-eligible, and the contradictingrisk_tagsstayed in the same report with nothing joining them (#409, Increment 1 of #410).The contradiction check already existed and was correct for the claims it could see:
semantic_assessment._assess_effectadmits a claim intocontradictoryonly whenpolicy_eligible, anddomain.pygrants that only to typed, high-confidence bases. Heuristic risk hints are deliberately excluded — a heuristic must never drive policy (#357). But one flag governed two different powers. Driving a verdict heuristics rightly cannot. Challenging a human assertion they should: a declaration sitting below an observation is not the heuristic gating anything, it is a human statement contradicting something the scan saw, which is precisely what a reviewer needs surfaced.Effect declarations are now monotone. Adding or escalating relative to the evidence stays silent — a reviewer calling an action more dangerous than the evidence proves needs no ceremony. De-escalating past a non-policy- eligible inference raises
declaration_below_inferred_evidence(report schema0.35→0.36): a review-level evidence gap naming the declared value, the inferred value, and the hint that produced it. The declaration remains the operative effect — heuristics still do not drive the verdict, and this row never blocks — but the action is not evidence-backed- pass until it is answered.Two answers close it, and the reviewer owns the choice: raise
effectto what was inferred — the row names the exact value, soImprove evidence:reads Raise action_surface.actions[].effect to 'external_communication' — or acknowledge the difference with the newaction_surface.actions[].overrideblock, which names theevidenceyou checked and thereasonit does not apply. An acknowledged override is accepted — the action is pass-eligible again — and is reported as one semantic review concern, so a run carrying one can never readpassed. It is a human assertion like every other declaration: the gap's template carriessuggested_patch_kind: manual,auto_apply: false,requires_human_review: true, andapply-patchesnever writes it.An override never silences
conflicting_effect_evidence: where policy-eligible evidence outranks the declaration, the existing blocking conflict is unchanged, no acknowledgement attaches, and the row now says the override does not reach it — a reviewer blocked there reaches for the field, and silently discarding it left them re-running against an unchanged message.Source evidence that agrees with the declared value does not exempt the row. A first draft exempted it —
support.search_kbdeclaresreadand carriesreadOnlyHint: true, so why make a reviewer defend a protocol annotation against a keyword? Because this resolver already refuses to pass on that annotation alone: with no declaration the same tool isinferred_effect_onlyand not pass-eligible, precisely because a hint outranks it. A declaration that merely restates the annotation must not buy what the annotation could not, or #409's hole moves rather than closes — and the corroboration would be drawn from content the tool source supplies about itself, which is not conditioned ontool_sources[].trust(an MCP server can assertreadOnlyHint: trueabout a destructive tool). The agreeing source is named in the row instead — "source evidence agrees with the declaration (mcp_annotation)" — which is what makes the override one line to write. The manifest row's owneffect,risk_tags,scopes, andoverridenever count as agreeing evidence for itself.samples/support_refund_agentcarries the two overrides this rule asks it for, so the shipped sample is the worked example.The acknowledgement is consumed everywhere the question is asked. Policy applicability asks exactly what the override answers — "does the higher heuristic effect apply here?" — so leaving the acknowledged claim unresolved there traded
declaration_below_inferred_evidenceformixed_policy_evidence: the reviewer followed the row's own instruction and landed on a differently-namedinsufficient_evidence. The override claim carriesoverridden_claim_ids, and_non_authoritative_effect_escalation_support, the action-policy predicates, and capability-policy matching all read that one authored list rather than re-deriving the comparison. The acknowledged fixture now reachesreview_requiredwith zero policy gaps.Each exception is a row, not a count.
semantic_coverage.acknowledged_overrides[](report schema0.36, packet0.12→0.13, verifier0.9→0.10) names the action, both readings, the hint source, any source evidence that agrees, and the human's evidence and reason. The packet's §1 and the PR comment (SHIP-ACTION-EFFECT-OVERRIDE-ACKNOWLEDGED) render one row per override, so a reviewer reads the exceptions rather than a number. Frozen0.12packets and0.9verifier artifacts still read forward; the field is absent there and an empty list is the honest reading.Blank-looking answers are rejected.
str.strip()leaves U+200B and U+2060 intact, so an override whoseevidenceandreasonrender as nothing to the reviewer they exist for validated, suppressed the mismatch, and restored pass-eligibility. Both fields now require visible content — the repository's ownhas_visible_contentsemantics, moved toschemas/text.pyso the schema layer can use it without importingcore, covering whitespace, controls, bidi marks, and every Default_Ignorable code point.The published manifest schema says what the CLI enforces.
docs/manifest-v0.1.jsonis advertised for live editor validation and accepted both anoverridewith noeffectand blankevidence/reason, whichmodel_validaterejects — telling a user their manifest is valid and then refusing it. The dependency is published as anif/then, the visible-content rule as apatterngenerated from the same code-point table the runtime check reads, andtests/test_manifest_schema_parity.pyruns twelve payloads through both validators and requires them to agree.Known limitation: an override whose inferred evidence later stops firing stays accepted and unreported. Distinguishing a stale exception from one that never applied needs the
basis: confirmed:<derivation_id>pin from increment 4 of #410, which is where it belongs. -
Every evidence gap now labels a tool the way a reader can use, in every gap kind.
EvidenceGap.subjectis a display label — identity lives insubject_id— but the policy evidence gaps (every row ofreport.policy_evidence_gaps, also merged intorelease_decision.evidence_coverage.evidence_gaps) put a raw 64-hex canonical tool id in that label, in two shapes:tool_v2_2c9ee6ae…andsupport.search_kb [tool_v2_445a25…].evidence_gap_headlineprints the label verbatim into the CLI'sImprove evidence:line, the decision reason, and the GitHub step summary, so a reader got a digest where a tool name belongs.samples/support_refund_agentcarriedsupport.search_kbin one gap list under both spellings at once.All three emitters — per finding (
cli/scan/decision.py), per action (core/lenses/action_surface.py), and per policy-pack rule (inputs/policy_packs.py) — now resolve the label from the tool catalog bytool_idand setsubject_idfrom the same tool, so the label became readable without the identity being dropped;policy_evidence_gap()previously had no way to carry it and leftsubject_id: nullon every row. Resolving through the catalog rather than from each emitter's own fields also removes a second divergence:ActionFact.provideris_normalize_token(provider or source_id or source_type), so a source id ofmy apiused to label one gapcreate_refund [my_api]and anothercreate_refund [my api]for the very same tool.#403 scoped the raw-id rule in
_validate_exclusion_ledgertoLEDGER_JOINED_GAP_KINDS, on the reasoning that a gap the ledger never joins may name its subject however it likes. A guard scoped to a set of kinds passes vacuously for every kind outside it, which is how these rows kept their digests; the rule now covers every kind, and since review 2 moved the join ontosubject_idit is no longer about joinability at all — it is what keepssubjecta label.LEDGER_JOINED_GAP_KINDSexisted only to carry the carve-out and is removed with it.The rule matches an id by shape, anywhere in the label, rather than by membership in the run's own catalog. A membership test saw neither spelling that actually shipped: the policy-pack emitter wrapped the id in a label, and a check plugin — validated on its declared
check_id, not on tool membership — can raise a finding carrying a stale or invented id that is in no catalog to compare against. An id that cannot be resolved to a catalog row now falls through to the check id rather than being printed raw.Field shapes are unchanged and
subject_idalready shipped in0.35, soreport_schema_versiondoes not move (STABILITY.md). -
Every stage that narrows the analysed surface now records what it removed, and the release decision can read it. Across two first-time adoption walks the same shape produced five separate failures: a stage computed the right signal, stored it, and did not connect it to the decision (#403).
The sharpest instance is a fail-open in exactly the reward-hacking shape this product exists to catch.
github/github-mcp-server#3076addsdelete_repository—destructiveHint: true,readOnlyHint: false— to GitHub's official MCP server. With the reviewed declaration still listing the 116-tool surface from the base commit, the run reportedunbound_tools: 1besidegap_count: 0andpass_eligible: true, and named the new tool exactly once in the whole report: as a row intool_catalog. The checks that would have blocked it are correct — declaring the tool producesSHIP-POLICY-APPROVAL-MISSINGandSHIP-ACTION-DESTRUCTIVE-ROLLBACK-MISSING— but the tool left the analysed surface before they ran. And it could not be declared without editingshipgate.yaml, which is a release trust root a coding agent cannot self-approve, so the honest options were "invisible capability" and "blocked on a trust-root edit".Reports now carry
surface_exclusions(schema0.35): one typed{stage, subject, reason, source_ref, detail, accounting}record per subject a stage removed, from the binding graph, adapter parsing, and surface completeness.detect --jsonandtrigger --jsonemit the same record for the stages they own — a capped discovery walk, an unresolved manifest scope, a glob-matched source the real adapter rejects, an unclassified change set — replacing four ad-hoc spellings of the same event with one.accountingis what makes the record checkable rather than decorative:evidence_gap(a gap row names this subject),route_blocked(the stage withheld its verdict and itsnext_actionrepairs it), ornot_claimed(nothing in the repository claims the subject as capability).A conservation invariant is enforced at emission:
observed == analysed ∪ excluded, every excluded subject appears in the ledger, everyevidence_gaprecord is backed by a gap row carrying the same subject, an excluded tool the decision did gap is never recordednot_claimed, and a subject this change newly excluded can never benot_claimedeither. Theunbound_tools: 1 / gap_count: 0state is now unrepresentable rather than something each call site has to remember.Every evidence gap that names a catalog tool now names it the same way.
partial_binding_evidenceand the binding graph-issue rows used to carry the raw canonical tool id (tool_v2_2c9ee6…) while every other emitter renderedname [provider], which both read badly inImprove evidence:and made a tool unjoinable with itself — the ledger looked up one spelling and found the other. The invariant rejects a raw id reaching a joinable gap subject.The gate itself moved only where a diff proves it should.
binding_surface_diffgainedadded_unbound_tool_ids— head exclusions minus base exclusions — and a tool in that set raises amissing_binding_evidencegap naming it. A pre-existing unbound catalog entry is unchanged:samples/large_multi_framework_agenthas 58 by design, and gating on those would make declaring an OpenAPI spec or an MCP server self-blocking. Catalog membership is still not evidence of capability — a capability the diff introduced and nothing judged is a different claim. A plainscanhas no base, so nothing here fires on one.skipnow requires positive evidence.TRIGGER-DOCS-ONLY-NEGATIVEis a legitimate skip: it classifies every changed file and concludes.no_matchclassifies nothing, and the same PR is where that mattered — a fully readable diff whose only relevant file ispkg/github/__toolsnaps__/delete_repository.snap, reported as "nothing in this PR signals a tool-surface change" becauseTRIGGER-MCP-EXPORT-CHANGEDmatches**/*mcp*.jsonand that file is named neither. A non-empty change set no rule classified now returnsevaluation_status: "unclassified"withshould_run: nulland anext_actionrouting forward to the scan, rather than a skip nobody can falsify; an empty change set keepsno_match, because there it is a fact about the PR. This is the #308 monotonicity rule — already accepted for diff readability — applied to diff comprehension. Trigger catalog0.3 → 0.4also addsTRIGGER-MCP-TOOL-SCHEMA-CONTENT, which recognises an MCP tool definition by its content (an MCP input schema beside MCP annotation hints) instead of by a naming convention the repository never agreed to; the same glob also missedmcp-server/tools.json.Two more places published a skip nobody could falsify. A matched capability rule now overrides
stop_conditions: the block's premise is "this workspace is not an agent project", read bydetectfrom the working tree, and a matched rule is evidence from the diff, which can carry whatdetectnever saw — the.snapfile above is invisible tosuggested_sources, so the stop held and discarded the very rule that recognised it. And a skip may no longer rest on changed files no rule classified: a dependency bump beside an opaque capability file matched adry_runrule that covered only the manifest, and published an advisory skip over the sibling. Coverage is now per path.The tri-state verdict reaches the consumers that act on it. The Claude Code hooks branch on
evaluation_statusinstead of coercingshould_run: nullto a skip, and say which of the two withheld states applies rather than claiming a match;decide-shipgate-relevance.mdteaches the tri-state and the new precedence; andtrigger_catalog_schema_versionmoved in step across the contract payload,.well-known, the rendered local contract, and the docs — a drift a new cross-surface equality test now catches.Finally, a base comparison that was requested and could not be performed no longer reads as one nobody asked for.
binding_surface_diffgainsbase_comparison_requested,VerificationContextgainsbase_comparison_unavailable, and that state raises one gap naming the unusable base rather than concluding an unbound tool is pre-existing from a comparison that never ran — the weakeningdocs/engineering/ai-coding-workflow-verifier.md§2.3 forbids. Ledger rows in that state areunverified, nevernot_claimed.The tri-state reaches the GitHub Action too.
trigger_actionread the rawstop_conditions_firedbit before the winning verdict, so the Action republished a skip the runtime had just refused, and it collapsed both withheld states intonone. It now projects the verdict, returnswithheldrather than a value that reads as a decision, andaction.ymlexportstrigger_evaluation_statusso a workflow can tell "run the scan" from "repair the input". First adoption stays out of the failed-comparison route: a base with no manifest was read successfully and simply has no gate, so asking the adopter to regenerate a base report that cannot exist made adoption over a partially-wired catalog unfinishable.Three integrity gaps in the new evidence closed.
accountingjoins to its gap through an explicitaccounted_bypointer instead of a subject string two catalog tools can share;gatedand the newgap_backedare validated against the rows they summarize, in Pydantic and in the invariant, because a count nothing checks can be forged past both; and the cap's guarantee is now the accurate one — everygap_backedrow survives truncation, whileroute_blockedandunverifiedrows may be capped, since their accounting is one whole-run fact a single row proves as well as five hundred.Adapter omissions are recorded again, from a typed fact rather than from prose.
LoadedToolSource.omissionscarries the entries an adapter read and refused — the MCP loader records both of its skip branches — so an entry that genuinely never entered the catalog reaches the ledger, while the warnings about tools that did load stay out of it. -
verify --previewof a head that is not checked out now asks for the checkout, instead of stopping. Preview reads project markers from the working tree, because that is the tree theinitit recommends would write to, so previewing some other ref establishes no project — on the reported pull request the changed directory exists only on the PR branch. That state routed to a human withmust_stop: true,command: null, andallowed_next_commands: [], which ended the loopallowed_next_commandspromises in one move; the remedy, derivable from the very ref preview was handed, was never stated (#397).Nothing about the change is in doubt there. What is missing is an input — a working tree holding the commit under review — and producing it is one mechanical step the caller owns. The route is now the
fetch_baseaction that already exists for exactly this shape:agent_action_required, no command (Shipgate never writes to a caller's worktree), and anexpectsthat names the input. The two causes that no checkout can repair — evidence the change deleted, an unreadable inventory — keep their human route. Plainverifyis unaffected: it reads--headfrom the object database, not the worktree.expectsnames a commit id, and the rerun it asks for is pinned. The step being requested movesHEAD, so a route spelled with the caller's own revision expression does not survive it:--head HEAD~1names one commit before the checkout and its parent after, and following the route walked history backwards one commit per iteration instead of resolving. AHEAD-relative--basere-ranges across the same checkout, which is quieter and worse — the rerun succeeds against a diff nobody asked for. Both refs are resolved to immutable ids before either is published.The requested checkout also makes the preview's control pointer stale. It bound no HEAD identity, so
agents-shipgate agent control— the one refresh entry point — kept returning the samecurrent_control_idand the same unmet-looking request after the caller performed it, which is how a refresh-driven controller repeats an action forever. The pointer now binds the worktree the preview actually read.fetch_baseaccordingly means "make this input available" in both of its senses. Which sense a route asks for is read fromexpects, by every consumer: the adoption scorer recognizes a checkout of the requested commit — not a fetch, not a path-restoringcheckout --, not a checkout of some other commit — and still requires a fetch for a ref request.The whole recovery is published in
expects, which the envelope never truncates, and the instruction leadswhy, which it caps at 400 bytes. A sufficiently long branch name had pushed the checkout instruction and both pinned refs out of the bounded field, leaving a consumer to re-derive the rerun from its ownHEAD-relative request — rebuilding the walk.The pointer binds the worktree preview read, not only its HEAD. Preview routinely routes on uncommitted evidence — an untracked
agent.pybeside an untrackedpyproject.tomlis a project — and deleting one of those files moves neither HEAD nor its tree, so the stale route stayed current. The path set is derived from the live worktree on both sides, so any path entering or leaving it, and any content change within it, refuses the pointer. -
detectnow publishes the same per-candidateinitcommandsinitdoes when a workspace holds several agent projects. Its escalation handed the reader a JSON selector inside a shell command —init --workspace <agent_project_candidates[].path> --write— and no runnable command anywhere innext_actions[], so a preview that routed todetectreached a second dead end (#397).detect --jsonnow ranks the decision first, exactly as before (kind: "review",command: null, because naming one candidate would make the arbitrary pickinit --writerefuses to make), and carries one exactinit --workspace <candidate> --write --jsonbelow it per candidate, with theexecutable/argspair. Both commands build that list from one helper, so the two an adopter runs in sequence cannot publish different recoveries for one workspace; the workspace root stays out of it, since that is the scopeinitrefuses.Every candidate gets one. The ten-item cap that keeps a human refusal readable had been applied to the routing too, which left candidate 11 onward selectable and unrunnable —
detect --workspace samples --jsonfinds 22 projects and emitted 10 commands, and the reported pull request's repository has 25.And a candidate that already carries a manifest routes to
doctor --config <that manifest> --json, not toinit --write. A nestedshipgate.yamlis itself evidence of a project, so adopted directories are candidates too: on this repository's ownsamples/, 21 of 22 are, and every command emitted for them exited 2 on a manifestinitwill not overwrite whileexpectspromised a file that already existed. The exception is--agent-instructions, which makesinit --writethe advertised refresh and exits 0 — there theinitroute is kept, flags and all. Both commands' printed summaries mark those candidates and name thedoctorroute, from the one formatter they now share: the human and JSON forms of a single run had begun answering the same question two ways.Setup the caller asked for survives that route. A refused
init --write --ciwrites no workflow, and handing the adopted candidate a baredoctordropped the request silently; it now carries--cion aninitwith--writeomitted, which installs the workflow, leaves the manifest alone, and exits 0.The workspace root gets an answer too.
.is a real entry inagent_project_candidatesand rank 1 tells the caller to choose from that list, but it was the one candidate the routing skipped:initthere is the run that just refused, and--allow-unresolved-scopeaccepts the whole workspace as one scope, which is a different decision. It is now an explicit human route saying exactly that, and the printed lists mark it — a caption reading "re-run init on the one you are changing" over an unmarked.is the human form of a run contradicting its own routing.detect'scontrol.input_idnow covers the route it publishes, not only the classification behind it. Every emitted command is spelled for the entry point the process came in through, so the same workspace read asagents-shipgateand as/opt/custom/agents-shipgatepublished different commands under one identity — and that identity is the documented cache boundary for the answer. -
The first scan of an agent whose tools are imported symbols now scaffolds both layers it needs, instead of emitting nothing.
suggested-declarations.yamlwas only written once the binding layer was already closed, so during the two scans where an adopter is most stuck it did not exist, and the binding gap that was reported carrieddeclaration_template: null(#361).Two templates close that. A repository whose agent lists tool symbols static analysis cannot resolve extracts nothing at all, so it used to produce only source warnings routed to
review_warning— no path, no command, nothing to open. It now raises oneincomplete_surfacerow per source carrying the exacttool_inventoriesentry, joined bysource_idto the source it completes, and the tool-inventory skeleton is written with the symbol names the agent's owntools=[...]list publishes. Per source, not per symbol: six unresolved symbols are one mechanism restated six times, and attaching a repair to each row would have put raw loader prose back in the headline that grouping removed.Once that inventory is declared, the catalog is populated and nothing binds it to the root agent. That gap now scaffolds the closed-world
agent_bindings.declarationsrow with the agent, every catalog tool's exact selector, and the observed handoffs pre-filled — whilecompleteandreason, the two values that are a human judgement, stay<REVIEW_REQUIRED>. Merging the block verbatim after answering those two closesbinding_coverage.gap_countin one iteration. Past a ceiling of 50 tools the template is withheld rather than truncated:complete: trueclaims the listed tools are all the agent can reach, so a silently cut list would be false exactly where a reviewer cannot see it.Both instructions are also withdrawn once followed, and so is the diagnostic behind them. A reviewed
tool_inventoriesentry naming a source insource_idis the answer shipgate itself prescribes for that source's unresolved-symbol warnings, but those warnings stayed on the report andevidence_below_ie_thresholdgates on their raw count — so a repository that did exactly what it was told sat atinsufficient_evidenceforever, with no non-warning gap left to act on. They are now withdrawn when the manifest declares the source, per source, keyed on that reviewed completion relationship and never on tool names: a name subtraction cleared an unrelated source's warning by coincidence in one direction, and in the other it never matched an inventory that had correctly split a toolset symbol into the tools it exposes, so that source was prescribed the same inventory forever. Only the warning is withdrawn — the loader'ssurface_gapsentry stays, so extraction confidence is untouched, and an empty inventory still cannot reach a verdict pastSHIP-INVENTORY-NOT-ENUMERABLE. The inventory skeleton is deleted alongside it, the way the declaration scaffold already was.A display name two sources share is withdrawn against only once every source publishing it is complete: while any candidate is still owed an inventory the warning could be about that one, and once none is, the ambiguity no longer changes the answer. Both sides of the comparison are stripped, since the manifest permits surrounding whitespace in an id.
The closed-world
declarationsrow is also scaffolded for the other shape that needs it — an agent whose tool list static analysis could only partly read — and lists the agent's existing edges as well as the unbound catalog tools, because a row omitting a tool the repository plainly wires to the agent would be false. -
Every
<REVIEW_REQUIRED>insuggested-declarations.yamlnow says what a legal answer is. The one file an adopter is told to edit was the one file that did not name the vocabulary:effect:andauthority.mode:were bare blanks whilereport.jsoncarried their nine and four accepted values per gap, and completing twelve tools meant roughly forty-eight values looked up in a different file (#388).Each blank is preceded by a comment carrying either that field's
accepted_values— rendered from the gap's own list, never a second copy, so the two artifacts cannot disagree — or, where the answer is not drawn from a closed set, the shape it takes and which modes make it required. Theagent_bindings.rootblock additionally lists the agent objects the scan observed, with their source, for a human to confirm:objectmatches the agent's declared name rather than the Python variable it was assigned to, which is what made guessing it a coin flip. Nothing is filled in — a comment is not a value, and inferring the trust root from AST evidence remains the self-declaration surface #268 closed.Pasting an unfinished scaffold now also reports itself as one whatever field the placeholder lands in.
agent_bindings.declarations[].completeaccepts onlytrue, so its own type answered first with "Input should be True", which tells a reader nothing about the scaffold they pasted; the placeholder is rejected before field validation, so one wording covers every field. That check reads raw input, whichyaml.safe_loadcan hand back as a graph rather than a tree, so its traversal visits each container once. A manifest containing&loop {x: *loop}gets the structured config error and its agent-mode recovery payload rather than aRecursionError, and an acyclic alias DAG — the expensive case, doubling the walk at every level and materializing2**npath strings for one placeholder — is bounded by the size of the document. -
display_literalnow escapes Unicode noncharacters alongside the invisible code points it already covered. They are the same hazard — nothing reaches the reader, so two repository objects render identically — and two of them are worse: PyYAML rejects U+FFFE and U+FFFF outright, so an agent name carrying one made the generated declaration scaffold unparseable, because the document quoting that name in a comment could not be loaded at all. The encoding stays injective, soundisplay_literalstill recovers the name. -
verify --previewon a monorepo now names the project the pull request actually changed, instead of a repository root thatinitrefuses. The change-scope resolver draws a project boundary around a barerequirements.txtonly for directories the caller has already found agent evidence in — the whole boundary arequirements.txtbesideagent.pyhas. The preview call site passed no evidence at all, so the walk climbed past such a project to the workspace root, resolved no scope, and emittedinit --workspace <repo root> --write, whichinitthen refused deterministically: "holds 53 self-contained projects that define agents, and one manifest describes one agent surface."detecthad reported the same project correctly all along, so the two commands an adopter runs in sequence disagreed (#394).Preview now collects that evidence for the directories the diff sits under, and asks it only where the answer can change anything: a directory carrying a strong marker is already a project root, and one carrying no weak marker cannot become one. For the rest it reads what
detectwould find directly in that directory, through every rule that can put a file in its evidence set — framework-attributed Python, the artifact-glob detectors (Anthropic, OpenAI API, n8n, Conductor), suggested OpenAPI/MCP sources, and Codex plugin packages — over the same git-aware inventory, so an ignored file cannot make preview narrow to a directorydetectnever saw. On the reported pull request the first command an adopter sees goes from an eight-step recovery toinit --workspace python/agents/smart_closer --write.The probe reports three things as undetermined rather than as "no project here": the shared
max_python_filesbudget running out with a file still unread, an unreadable inventory, and a change that deletes the one file beside a requirements file that could have been the evidence — the head tree cannot say whether what it removed was that project's agent surface.Each of those routes to a recovery that can actually advance it, because one generic answer could not. A
detectat the same cap hits the same cap, so budget exhaustion emits a concrete higher-cap command; and a head-onlydetectcannot see evidence the change deleted — it reports the surviving project as the workspace's single scope and itsinitwrites a manifest for an agent the pull request never touched — so deleted evidence is a human route with no command at all. Causes accumulate rather than replacing one another, and deleted evidence outranks a cap, because raising a bound cannot find a file the change removed. An evaluated head that is not this worktree is the third such cause: discovery of the current tree answers about a different one, so no discovery command is offered there either — that route asks for the checkout instead (first entry above).Two blind spots in that probe are closed. It now bounds each directory's Python evidence to the files a
detectof that directory would reach — the firstmax_python_filespaths of its subtree in inventory order — so a directagent.pysorting after a thousand inert modules is no longer evidence preview can see and the scoped command it recommends cannot. And boundaries the change removes are derived from the change set before anything reads the head tree, since a deletedpyproject.tomlleaves nothing for a head-tree marker filter to find: a pull request deleting a whole project was silently attributed to whatever survived. -
detect's glob-based source suggestion re-ran the whole git inventory walk once per pattern — fifteen walks for one pass._candidate_files_matchingnow accepts an inventory the caller already built, which both fixes that and is what lets the preview evidence probe ask the same suggestion rule about a single directory rather than keeping a second copy of it. -
Project discovery no longer presents a truncated candidate list as a complete one. On a repository large enough to hit the Python-file cap,
detectandinitreported the agent projects found before the cap as if they were all of them — no cap warning, no--max-python-fileshint, and the project actually under review missing from the list the user was told to choose from. Theambiguousverdict short-circuited the truncation check, so"unknown"— the state whose entire purpose is to say the parse was cut short — was reachable only when one or fewer candidates were found, and the fail-safe was unreachable on exactly the repositories most likely to need it (#395).Truncation is now evaluated independently of ambiguity and reported beside it:
detect --jsonandinit --jsoncarryagent_scope_truncated, the human and refusal messages say the list may be incomplete and name the--max-python-filesremedy, andworkspace_signals.project_root_countbounds the claim with an uncapped, filename-only census of the directories that could be a manifest scope — every project-marker directory plus the workspace root, which is a candidate whether or not it carries a marker, because unmarked agent evidence is attributed to it as..Nothing publishes a terminal negative from a capped walk any more, and the guard for that is a second, wider field:
python_parse_truncated, the raw fact that the parse stopped at its cap.agent_scope_truncatedadditionally requires more than one candidate scope — right for a claim about the candidate list, wrong for a claim about the workspace, because a single-scope repository whose only agent sorts past the cap leaves it false while still hiding an agent. Every whole-workspace negative now gates on the raw field: the three negative-control diagnostics (SHIP-DIAG-NO-AGENT-SURFACE,-NON-AGENT-LIBRARY,-PURE-PROMPT-EXPERIMENT), each of which publishes astopthat routing turns intosetup_not_applicable;bootstrap's no-surface stop; thedetecthuman summary; the first-look classification line; and the trigger catalog's stop block, which gainedpython_parse_truncated: false. Adetectpayload missing any key that block reads is now reported asstop_conditions_evaluated: falserather than silently satisfying it, since absent is not false.init --writerefuses on a truncated parse too, and takes the same--max-python-filesflag. It runs its own discovery, so a bounddetectsettled on did not reach it: following the recommended route landed on aninitthat re-ran at the default cap, missed the agent, and wrote aCHANGE_MEmanifest with no tools at exit 0.The recovery from a capped walk is now an executable command rather than prose inside a human route. Raising
--max-python-filesis a mechanical, read-only retry that needs no decision, sonext_actions[0]is the same command you ran at a bound covering every Python file in the workspace —detect --max-python-files <n> --jsonfromdetect, andinit --write --max-python-files <n> --json(carrying the setup flags the run asked for) frominit. It cannot land back at the same cap, and frominitit settles the scan and completes the setup in one step. That command leads the ranked recovery whenever the parse was truncated: asking a human to choose from a list the refusal itself calls incomplete is the thing to avoid. Human review is reserved for choosing an actual manifest boundary.Two more routes that could not succeed are gone:
SHIP-DIAG-MCP-OPENAPI-ARTIFACT-ONLYandSHIP-DIAG-CODEX-PLUGIN-PACKAGE-DETECTEDname a rootinit --write, and setup routing ranks a diagnostic ahead of the advance, so on an unsettled workspace they published a command over the top of the route that would have said so; both now fire only on a settled scope and a complete parse. A single scope settles the manifest boundary and says nothing about whether the surface that manifest would declare was read. Andbootstrap's no-surface stop ignoredcodex_plugin_candidatesentirely, so a Codex-plugin-only repository — deliberatelyis_agent_project: false— stopped atdetectand never raninit.DetectResult.next_actioncarries the same rule. The CLI overwrites it with the routed action, which is why its stale branch survived: read as a library value — which is what the zero-install detector mirrors — a capped single-scope workspace still returned "Workspace does not appear to be an agent project. No action." Truncation is now checked ahead of the adoption and negative branches in both detectors, and in the script's human output.first_lookroutes the same way: its finalNext:line is the full-count retry rather than averify --previewthat walks past the recovery printed one line above it.The zero-install
tools/shipgate-detect.pycarries all the new fields (script version0.4.0), pinned by the parity test — which now includes a workspace that actually truncates, since every sample fixture sits far under the cap. -
Google ADK extraction confidence is now measured on the module, not hardcoded. The Python AST path set
extraction_confidence="medium"on every tool it produced, and the only code that ever set"high"applied to tools loaded from atool_inventoryartifact. Every gate tests!= "high", so no ADK repository could reach a pass from source however statically analysable it was:insufficient_evidencewas not a property of a repository, it was the framework's default first-run verdict. Reproduced on the most trivial case available — one file, twelve annotated module-level functions,12/12catalog tools reachable,0unbound,0source warnings — which still reported twelvelow_confidence_toolgaps and abstained. A condition that holds for every input carries no information: it could not tell a toolkit factory from twelve plain functions, and the remedy it prescribed was transcription — copy the twelve tools Shipgate had just extracted correctly intosuggested-inventory.json, adding no fact to the system (#393).A Google ADK Python entrypoint now reports
highwhen the adapter can show it read the whole surface, andmediumwith a named reason when it cannot. The proof is scoped to the file: one unresolved construct anywhere holds every tool the file produced, because a fully-resolved agent in a half-resolved module can reach tools nobody enumerated. Module-scoped reasons aredynamic_tools_expression,unresolved_tool_reference,unresolved_tool_expression,unresolved_tool_wrapper,dynamic_toolset,conflicting_tool_contract,unresolved_sub_agent,mutable_tool_binding(anything reachingagent.toolsafter construction, including through an alias,setattr, orgetattr(agent, "tools")),dynamic_agent_kwargs(Agent(**config), which hidestoolsentirely),unresolved_tool_wrapper(a recognisedFunctionTool/LongRunningFunctionToolwhosefuncthis module does not define — an import, an attribute, a lambda, or none at all), andshadowed_tool_definition(the name-to-definition map is flat and scope-blind, sotools=[helper]can resolve to a factory's inner function, a method lifted out of a class body, one of two conditional definitions, or a definition that a parameter, class, import,except ... as,case ... as,global, or later assignment rebinds — the tool is still named, its signature is not proven). Per-function reasons aredecorated_tool_function,variadic_parameters,untyped_parameter, andunrepresentable_annotation. The last two are the same defect twice: the JSON-schema fallback types an unannotated parameterstring, and it typesset[str],int | None,tuple[...], a Pydantic model, and eventyping.List[str]stringas well. A guess may not ship as a schema, so faithfulness is now checked by asking the emitter what it would produce and comparing it to what the annotation denotes — including the return annotation, which feedsoutput_schemathrough the same fallback. Thelow_confidence_toolevidence gap names the reasons instead of repeating one sentence on every AST tool in every repository.A recognised constructor is only ADK's while the name still refers to the import:
from google.adk.tools import FunctionToolfollowed byFunctionTool = replacementused to have a foreign factory read with Google's semantics (shadowed_framework_symbol), and afrom x import *can rebind anything the module defines (star_import_shadowing). Both are refused rather than guessed at.Two correctness fixes came out of the same review. Injected context parameters are identified the way ADK identifies them — by type, with
tool_contextas the name fallback — instead of by dropping every parameter spelledctxorcontext, which deleted ordinary model-visible inputs from the emitted schema. AndList[...]/Dict[...]now emitarray/objectrather thanstring, sofrom typing import Listis usable without holding the tool atmediumfor what was an emitter gap.Module-scoped reasons reach every tool the file contributed, not just its function tools. A module whose only tools come from a resolved OpenAPI or MCP toolset still has a tool set the file could not prove —
Agent(**config)beside a resolvedMcpToolsetis the case — so those tools are lowered too. They are only ever lowered, never raised: this step cannot promote a tool the adapter did not extract. They also survivetool_identitymerging: an identity binding proves two observations describe the same operation, which says nothing about whether the module one of them came from exposes further tools, so a member's unproven tool set caps the canonical tool. Reasons about a single tool's own interface still resolve in the primary's favour — that is what a reviewed inventory is for — and the evidence gap for an unproven set now names the construct to fix instead of asking for an inventory or spec the repository has already supplied._surface_is_completechanged with it: an AST source type used to be disqualified outright, which was the same constant one layer down and would have keptincomplete_surfaceopen on a proven surface. Membership now poses the question and the adapter's own attestation answers it. Saying nothing still reads as incomplete, so adapters not yet taught to answer — LangChain, CrewAI, the OpenAI Agents SDK static path — keep their previous verdict, an unclassified new warning demotes its module automatically, and a wildcard exposure still outranks any completeness claim.Net effect on the reported subject: a fully static ADK project with its actions declared reaches
passedinstead ofinsufficient_evidence, and one without them is asked for the effect and authority declarations a human genuinely owes rather than for a transcription it cannot learn anything from. -
A tool inventory now completes the source that asked for it, instead of shadowing it.
incomplete_surfacefires for every statically-extracted tool on a first ADK/LangChain/CrewAI/n8n scan, and the only remedy the tool offered was "save the skeleton, reference it from<framework>.tool_inventories". Following that instruction exactly made things worse: the inventory was loaded as an independent source, so its entries were added beside the extracted tools rather than joined to them. On the reported subject the catalog went from 12 tools to 18 with only 12 distinct names, the reachable ratio fell from 6/12 to 6/18, theaction_surfacerows that used to resolve becameambiguous_tool_selector, and the gap that asked for the file was still open. The loop had no third step (#386).<framework>.tool_inventories[]entries now takesource_id, naming the tool source whose surface the file enumerates. Each entry matching a name that source already exposes is joined to that observation, so the catalog keeps its size, the merged tool inherits the inventory's high extraction confidence, and the gap closes. Entries the source does not expose stay standalone — an inventory exists precisely to disclose tools static extraction missed, and a tool nobody wired is still honestly reported as unbound.Nothing is joined by name alone.
source_idis a manifest declaration desugared into the same reviewed-binding engine astool_identity.bindings, one binding per matched name; a name a source exposes twice implies no join and asks for an explicit binding instead, and a reviewed binding that already claims an observation always wins. The prescribed remediation text and thesuggested-inventory.jsonnote now name the field, and an inventory declared without it that shadows a low-confidence source says so insource_warningsrather than degrading in silence. Inventories that genuinely describe a separate surface keep working unchanged.Completion adds evidence and never removes it. A canonical tool now answers to the
source_type/source_idof any observation bound into it, so anaction_surfacerow already written against the completed source — including one Shipgate scaffolded itself, which qualifies rows bysource_id— keeps resolving instead of becomingunresolved_tool_selectorthe moment the inventory is applied. Both qualifiers, given together, must still be satisfied by the same observation. Merging also backfills what only the source knew (output_schema,owner, function signature, auth type/mode/credential) wherever the reviewed inventory is silent; previously a completed n8n tool came back high-confidence with unknown auth and no owner, trading the closedincomplete_surfacegap for apartial_authority_evidenceone. Disagreements between two populated values remainconflicting_tool_identityrather than a silent overwrite.That erasure was also suppressing findings, not only degrading evidence.
samples/support_refund_agentbinds a-> strSDK function to a reviewed inventory that is silent about output, and the merge dropped both the AST-derived{"type": "string"}schema and thesdk_functionsource type thatSHIP-SCHEMA-FREEFORM-OUTPUTfalls back on — so the shipped golden recorded no free-form-output finding for a tool that plainly returns free-form text. The finding is restored (one new MEDIUM review item; the sample's verdict and its five blockers are unchanged).The prescribed entry is also YAML-safe: source ids are unconstrained strings and generated framework ids embed the configured path, so a comma used to split
source_id: google_adk:agent,prod.pyinto two keys and the exact text the tool printed failed manifest validation. Encoding escapes every non-ASCII code point rather than emitting it literally — PyYAML rejects a stream carrying C1 controls or a lone surrogate outright, and silently normalizes U+0085 NEL to a space, so an id containing NEL round-tripped to a different id and the remediation named the wrong source.Identity aliasing covers the whole selector surface, not just the source qualifiers. Completion also rekeys the canonical
tool_idfrom an observation-derived hash to a binding-derived one, and_action_selectoremitstool_idon every row it scaffolds — so the generated declaration becameunresolved_tool_selectoragainst the very inventory the tool had just prescribed. A canonical tool now answers to the id each of its observations carried while unbound, and the rule reaches every selector consumer:_action_has_policy_controland_matching_suppressioncompared the canonical fields directly, so a source-qualifiedrequire_confirmation_for_toolsentry silently stopped applying (reporting a missingconfirmation.requiredand moving the verdict toblockedon an untouched manifest) and a source-qualifiedchecks.ignorewent inert. Alias ids resolve selectors only; they never enter the catalog partition thatagent_bindingsreads.Preserved evidence is now conflict-checked and traceable. Backfilling "the first non-empty value" resolved genuine disagreements by observation-id order: two members reporting
owner: team-aandowner: team-bproduced a tool owned byteam-awith no issues andpass_eligible=True. Every contributor tooutput_schema,function_signature,owner, andauth.credential_modeis compared — the primary included — and more than one distinct populated value isconflicting_tool_identity, which makes the identity non-pass-eligible. (auth.sourcenames the extractor that read the record, not the credential, so it is preserved without being compared.) Each preserved value records the observation that supplied it, and a finding raised on one cites that artifact: the restored free-form-output finding now points atagents/refund_agent.py:5, where the-> stractually is, instead of an inventory JSON containing no output schema at all. -
An input that is not there is no longer reported as an input with the wrong shape, and no command creates the workspace it was asked to inspect. Three reports, one class.
verify --previewgiven a--workspacethat did not exist created the entire four-level path, wrote a full artifact set into it, and exited 0 — so a typo produced a confident result about a workspace that was never there, and in CI both signals a caller can gate on read healthy. The leftover directory then blocked thegit clonethe reporter had skipped, turning one missed step into a second, unrelated failure (#389).An absent
--workspaceis now an invocation error —config_error, exit 2, decided before any directory is resolved or created — on every command that takes the option,--previewincluded. Preview's documented "always exits 0" is a promise about workspaces it evaluated; there is nothing here to evaluate. The sweep that enforces this is generated from the live command table, so a new--workspacecommand cannot quietly reopen the hole. It also closes four defects found while closing the first:init --write,audit --host, andverification prepare/workerraised bareFileNotFoundErrortracebacks,install-hooks --writewrote hooks into the mistyped tree, andmcp auditanswereddecision: allowabout a directory that did not exist.doctorreported an absent manifest as a malformed one — "Config file must contain a YAML object" — for a file that had never been created. The routing knew the difference all along, so the control envelope contradicted itself:control.reasonsaid fix the file whilecontrol.next_actionsaid bootstrap from scratch, and an agent reasoning from the reason edited a file that was not there. Absent, empty, and present-but-not-a-mapping now produce three distinct messages, classified where the read failed rather than after the bytes have been flattened tob""(#384). The same conversion in the diff-input path — "Workspace is not inside a git checkout" for a path that did not exist — is gone with it. -
A manifest type mismatch is an edit, not a bug report. A YAML mapping where a list belongs —
google_adk.tool_inventories:with keys under it, the prescribed remedy for the first gap most ADK adopters hit — was reported asinternal_errorwith "this is a bug — please file an issue", naming no field, no file, and no line. Pydantic convertsValueErrorandAssertionErrorraised inside a validator into aValidationError, but letsTypeErrorpropagate past the config-loading boundary (#387).Every validator in the manifest schema now raises
ValueError, so the same mistake producesconfig_error(exit 2) naming the manifest path and routing toedit, exactly as a bad key inside a correctly shaped list already did. Messages also name the shape that was written — "must be a list of artifact paths, but is a mapping" — and a build-time sweep fails ifraise TypeErrorreappears anywhere underschemas/, which closes the class rather than the instance. -
A Google ADK sub-agent's tools are part of the analyzed surface, and a tool the gate did not look at can no longer go unmentioned. On the canonical ADK multi-agent shape — a coordinator with
sub_agents=[salesforce_agent, sap_agent]— every tool the sub-agents owned fell out of the root-reachable graph, and none of the 25 evidence gaps named one. On the reported repository the excluded half was the half a release gate exists to judge: three financial writes, including one that sets opportunities toClosed Wonand one that creates an SAP sales order (#385).ADK routes a handoff by the sub-agent's
name=, butsub_agents=[…]spells the Python variable the agent was assigned to. Reading the variable as an agent name produced one phantom node per sub-agent, owning no tools, so the handoff landed on a node with nothing behind it and the real agent stayed unreachable. The two spellings are now reconciled from the module's own assignments, resolved innermost-out through the enclosing scopes so that two factories reusing one local name cannot cross their sub-agents. This also collapses the duplicate nodes the graph used to report.An element that cannot be resolved to an agent definition — built inline, imported from another module, behind a non-literal
sub_agentsvalue, or rebound ambiguously within one scope — now fails closed as partial evidence naming the spelling. It no longer becomes a node of its own: an empty tool set on a node named after an import reads as proof the sub-agent has no capability, which is the opposite of what is known about it. Naming two of three sub-agents likewise no longer reports the two as the whole handoff set.agent_bindings.declarationswas the documented remedy and could not work. Declaring an agent seeded a synthetic node for it, and for an agent the scanner had already observed that second node made the name ambiguous, so the resolver rejected names its own scan had emitted. Declarations now reuse the observed node, and a genuinely ambiguous name says how many agents share it and which sources they came from instead of reporting the name as unknown.Finally, a tool bound to an agent the configured root cannot reach now gets an evidence gap naming it. Everything downstream of the binding graph is narrowed to root-reachable tools, so such a tool is never judged; before this it was not mentioned either, leaving the ratio
6/12 catalog tools reachableas its only trace. This covers tools whose owning agent the scan identified — a hole in the graph Agents Shipgate built. A catalog entry that no agent binds at all is a different claim and is unchanged: catalog membership is deliberately not evidence of capability, so declaring an OpenAPI spec or MCP server does not become self-blocking. -
One command runs Agents Shipgate from this checkout, and
doctornow says which Shipgate answered. Running the CLI from a source tree meant either a bareagents-shipgate, which resolves throughPATHand can silently execute a pipx or base-conda copy — a0.8.0shadowing a worktree makes new subcommands look "missing" — orPYTHONPATH=src python -m agents_shipgate, which is correct but has to be discovered. A console script promoted from an environment that no longer exists is worse than either: it dies withModuleNotFoundErrorbefore a line of Shipgate runs, so nothing in Shipgate's own output can explain it, and the epic's reproduction had a user drop into a terminal at exactly that point (#334, #338).A repository launcher,
./shipgate, is now the canonical contributor command, and CONTRIBUTING.md, AGENTS.md, and CLAUDE.md all use it. It needs no installation, no activated virtualenv, and noPYTHONPATH: it puts this tree'ssrc/ahead of every installed copy (in child processes too), and selects an interpreter —AGENTS_SHIPGATE_PYTHON, else the project virtualenv, looked up in the main checkout as well so agit worktreeshares it — re-executing exactly once, guarded against looping. It announces itself throughAGENTS_SHIPGATE_CLI, the operator override the invocation policy already honours, so every command it prints back is runnable as printed; without that itsargv[0]reads as theshipgateconsole script and the policy would emit commands a clean checkout cannot run (#322). An operator who set the variable themselves still wins.doctor --jsonpayloads — and everydoctoragent-mode error line, including the discovery failure that prints no payload — now carry anenvironmentblock: the interpreter and whether it is supported, the launcher and every Shipgate console script onPATHwith the interpreter each shebang names, the import source and whether it is a checkout or an install, the installed and imported and source-tree versions, andmismatches[]— each with a severity and, where one exists, a runnable recovery command. Nothing runs an interpreter or executes a console script to find out: a stale wrapper is identified from its shebang, because a wrapper that cannot start is exactly the one that cannot report on itself. A source checkout out-voting an installed distribution is not reported as a mismatch — that is the intended state, and an editable install's metadata lags every version bump by design.The recovery is ranked, because
pip installis not always the first step: an interpreter created withvenv --without-pipanswerspython -m pip install …withNo module named pip, so emitting that alone would promise a recovery that fails on its first token in exactly the environment the recovery exists for.ensurepipis proposed ahead of it whenpipis absent, and an interpreter with neither gets the diagnosis and no command at all rather than one that cannot run.New agent-mode error kind
environment_error(exit 4), emitted by the launcher before Shipgate is running and carrying the sameenvironmentblock; it is published indocs/errors.json. New public helperagents_shipgate.invocation.render_cli_override, the host-rules inverse of howAGENTS_SHIPGATE_CLIis parsed — needed now that something writes the variable, so a checkout path containing a space survives the round trip. No schema or contract version changes. -
The launcher announces a spelling the operating system will actually start. A shebang is a POSIX kernel feature, so
.\shipgateis a file Windows will not execute — and announcing that path throughAGENTS_SHIPGATE_CLIpublished recovery commands that could not run there, which is this launcher's own defect relocated to another platform. It now announces<interpreter> <launcher>wherever the file cannot be started on its own: on Windows, and on a copy that lost its executable bit.python shipgate …is the documented Windows spelling, and it is the one that gets emitted. A new Windows CI job covers the entry point, the announcement, and theos.name == "nt"re-execution branch — including that a non-zero status survives the hop, which is exactly what a branch that spawns instead of replacing the process can lose. -
Two virtual environments over one base are no longer one interpreter.
runs_this_interpreterresolved both paths before comparing them, and a POSIX virtualenv'sbin/pythonis a symlink to the interpreter it was built from — so two unrelated virtualenvs collapsed onto the same binary despite having differentsys.prefixvalues and differentsite-packages. A console script pointing at a different environment reported clean. The comparison no longer dereferences, matching the rule the launcher already applied when deciding whether to switch interpreters; the two copies are now pinned to each other by test. -
PATHlookup follows the shell's rule, not "a file exists there". A regular file without the execute bit is skipped by POSIX command lookup, which continues to laterPATHentries. Stopping at it described a wrapper the caller's shell would never run and hid the stale-interpreter diagnostic for the one it would. Executability is now required on POSIX, andPATHEXTdecides on Windows. -
A trampoline target must be a command, not a mention of one. The
exechandoff was found by searching the whole wrapper, so a comment such as# old target: exec "/deleted/python"above a workingexecreported a healthy wrapper asconsole_script_interpreter_missing. Lines are now read in order withexecrequired in command position and comments skipped: a diagnostic may not be derived from a string the shell never executes. -
A quoted program token is read before it is judged to be ours.
retarget_commandlocated the program by scanning to raw whitespace, on the argument that our console-script names contain none — true of the names, and irrelevant to the strings they appear in. A quoted interpreter path whose directory is named after this project, which cloning it into~/agents-shipgate worktree/produces, was cut at the space; the remaining'/tmp/agents-shipgatehasagents-shipgateas its basename, so apython -m pip installrecovery was rewritten to name the Shipgate entry point instead. That is not an unrunnable command but a runnable one that runs the wrong program, and the dangling quote also cost the action itsexecutable/argspair. The span is now found with quoting honoured and the token's value comes fromshlex— the same grammar the string was rendered with — with the two cross-checked, so a disagreement leaves the command untouched rather than rewriting it wrongly. A correctly quoted Shipgate path ('/opt/my tools/agents-shipgate') is now retargeted where it previously was not. -
A
#!/bin/shconsole-script wrapper reports the interpreter itexecs. An interpreter path containing a space cannot go in a shebang, sopipwrites a shell trampoline instead. Reading only the shebang reported/bin/sh— which exists and is not the running interpreter — so a healthy install raisedconsole_script_runs_other_interpreteronce per alias, while the interpreter that could actually go stale stayed invisible. Theexectarget is now parsed; an unrecognised handoff reportsnullrather than the shell. -
An
insufficient_evidenceverdict now leads with the gap you can close, and the three lines that announce it agree. The reason counted source warnings — the symptom — and demoted the one actionable gap to a secondary line, whileagent_summary.first_recommended_action(the field the agent contract routes coding agents to) contradicted that line outright: "applying patches does not clear an evidence verdict, so no machine-applicable fix is available", printed directly beneathImprove evidence: … Target: shipgate.yaml#agent_bindings.declarations(#362). For a coding agent that is a dead end, and the cheap ways out of a dead end are the onesforbidden_actionsenumerates.release_decision.reason, the short-formImprove evidence:line, andfirst_recommended_action.whynow project one selected gap — the firstevidence_gaps[]row that names a nonblank normalized target or carries a publishable command, falling back to the first row when nothing is addressable. On aninsufficient_evidenceverdict with an addressable gap the three name the same gap and the same target — or, for a row carrying only a command, the same command; the reason readsInsufficient evidence: <what is unproven> (<subject>). Fix at <target>.(orRun: <command>.)Context: <counts>…. And on every verdict, "no machine-applicable fix is available" is unreachable while any gap is addressable; where it still appears, it is true. Outside that first case the three surfaces answer different questions on purpose — with no addressable gap the reason keeps its threshold wording, and underreview_requiredit stays severity-driven — and the published contract now says so at exactly that scope.first_recommended_action.kindstays"info"on the evidence-first branches — a statement about the summary projection, not about the gap rows, which may carry an exact command (the stale---diff-frombase report is regenerated by one) alongside the reviewed declarations a human must write.Every value these one-line surfaces interpolate is repository-derived — a gap subject is a tool name, a policy pack authors
expects, a semantic gap'spathembeds a tool name — so each is forced onto one line in the shared projection rather than at one call site, and the GitHub step summary now escapesrelease_decision.reasonthe wayreport.mdalways has. Without both, a value carrying\nControl: completeforged a line under the real one.Addressability is decided after normalization, by one shared predicate every consumer calls, and normalization is split into three questions that had been conflated. Display renders a value on one line without rewriting it: nothing is deleted, and anything that would not reach the reader as itself — controls,
U+2028/U+2029, bidi marks, lone surrogates, and invisible (Default_Ignorable) code points — becomes a visible<U+XXXX>escape. For identity-bearing values that encoding is reversible and injective:<is escaped too, soa\nb.yamland the literal filenamea<U+000A>b.yamlrender differently andshipgate\u200b.yamlcan no longer impersonateshipgate.yaml. Prose keeps<as ordinary punctuation and additionally folds whitespace; paths and commands never fold, soconfigs/foo bar.yamlkeeps both spaces andpython -c 'print("a b")'stays the program that was written. Visibility asks whether a value names anything at all, using Unicode Default_Ignorable rather than a general-category guess, so a path made only of ZWSP, VS16, or CGJ is not addressable. Executability is all-or-nothing, judged on the authored value, and a publishable command is published byte for byte: any control, bidi, or invisible code point, or any whitespace other thanU+0020, suppresses it entirely, and a publishable one is never trimmed. Deleting a zero-width character fromr\u200bm -rfauthors a program the repository never wrote; trimming a leading NBSP silently changesargv[0]; and trimming a trailing space breaksprintf foo\\, whose second token legitimately ends in one. Blank accepted values are dropped rather than rendered asAccepted values: , ., and a suppressed command produces noRun:line and anullrepair command instead of an empty one.A gap counts as addressable when it names a target or carries a publishable command.
pathandcommandare independently nullable on the wire, and aprovide_sourcerow carrying only an exact regeneration command is as actionable as one naming a file — reading the path alone letImprove evidence:printRun: …while the field agents read said no machine-applicable fix existed.Two copy rules that made the dead end look larger than it was came with it. Warnings that restate one recognized mechanism collapse at render time — six
Google ADK agent 'x' references unresolved tool 'y'.lines become one naming the cause, every affected symbol, and the two surfaces that close it — inreport.md,packet.md/packet.html,verify's fix-task remedies, and the CLI--verboselist, which now prints the mechanism count beside the raw one. Grouping is structural, not textual: a mechanism declares which fields are context (two ADK agents never merge into one row, and a symbol they share is never counted twice) and which are subjects (a binding id stays attached to its tool, so two sources cannot cross-product). Decoding is exact rather than delimiter-guessing — every interpolated value isrepr()of a string, so the decoder reads a string literal at each field position and a value containing the separator is read whole instead of being cut in half. A message that does not decode as a registered mechanism wrote it, including a composite one carrying two invalid binding members, keeps its own text rather than a merged one — rendered through the same one-line display projection as every other group, because opaque loader text still reaches surfaces that do not collapse newlines, and replaced by a visible placeholder when it has no printable content at all (a blank bullet hid the very thing the gate was reporting).report.json,packet.json,SourceWarningGroup.warnings, andevidence_coverage.source_warning_countall keep the loader's bytes: that count is a gating input, and folding it would silently recalibrate the threshold.A
tool_identity.bindingsmember that matches nothing now gets the guidance that fits its cause. A configured source that produced no observations states the rule and points atshipgate.yaml#agent_bindings.declarations; asource_idthat names no configured source at all — a typo — is told to correct the selector, because no binding declaration can repair one. Both used to report only that the member "matched 0 observations", the arithmetic that sent readers back to declare more bindings over the same empty source.verify's fix task keeps typed source-warning repairs. A blanketsource_warningskip discarded the stale---diff-fromgap, which carries a path, an expectation, and the exact regeneration command, leaving only the raw warning prose — so the handoff named a different repair from the one the selected gap names. Only pathless, review-only warnings fall through to prose now, and every field the typed path interpolates is one-lined before it reachesfix_task.instructions[]andallowed_repairs[].target/reason/command— durable machine-facing fields thatagent_resultcopies verbatim intorepair.instructions,suggested_fixes, andagent_repair_instructions.Verdict strictness is unchanged.
_MAX_TOLERATED_SOURCE_WARNINGSand_LOW_CONFIDENCE_TOOL_RATIOstay frozen,evidence_below_ie_thresholdreads the same counts, and no schema version moves. This is ranking, copy, and render-time grouping, plus one consistency invariant with a test. -
Verifier schema
0.8 → 0.9.capability_review.policy_weakening_provenis an emitted field, and a published schema identifier never gains one: an artifact still declaring0.8failed validation against the frozen v0.8 schema every consumer pins to.0.9carries the field,docs/verifier-schema.v0.8.jsonkeeps its published bytes, and artifacts declaring0.8and earlier still read — the field defaults tofalse, which is exactly what "this artifact recorded no base-vs-head comparison" means. The model now also rejects the contradiction the docs already forbade:policy_weakening_proven=truerequirespolicy_weakened=true, so a payload cannot route as safe while telling a human the policy was weakened. -
The PR comment reports the proven fact, not the routing flag. Headline, control reason, and fix task were made honest for an unprovable policy direction, but the generated
pr-comment.mdstill printedPolicy weakened: trueoffpolicy_weakened— the fail-closed flag that is raised precisely when nothing was compared. It now printsPolicy changed, weakening unproven: truewith the reason, on every first-adoption and no-base run. The route it reports is unchanged. -
The policy comparator honors the split check-id aliases. Runtime severity resolution already did; the Tier B base-vs-head comparison still read literal keys, so it produced both kinds of wrong answer — a head that adds an explicit override for the new id lowered the applied severity with no key change on the umbrella (missed), and a head that drops a redundant explicit override changed no applied severity at all (falsely reported as a weakening). Resolution now mirrors the applier exactly: the exact id wins, then the umbrella.
-
Accepted debt survives the split. A fingerprint hashes the check id, so every baseline entry recorded against
SHIP-VERIFY-POLICY-WEAKENEDstopped matching the renamed no-base finding — moving acriticalaccepted item from matched debt to new, and its decision fromreview_requiredtoblocked. Baseline matching now offers the pre-split fingerprint as an additional candidate, scoped to declared split targets and still required to agree onsupport_hash, so exactly the renamed row matches and no unrelated debt is absorbed. -
The headline is bounded once, at the end. The evidence-gap provenance note was appended after the reserved-budget composition, silently spending the room held for the human-review requirement: a long multibyte blocker title plus one gap note produced 443 bytes and the 400-byte compact projection dropped
a human must review it.from bothreasonandhuman_review.why. Every later addition now goes through one composition, the configured-manifest path in the adoption suffix is bounded, and when room runs out the parts yield in priority order — the gap note first, never the verdict, the named cause, or the requirement. -
Unicode format controls are stripped from headline material. C0/C1 filtering missed the category that matters most: U+202E RIGHT-TO-LEFT OVERRIDE and U+2066 LEFT-TO-RIGHT ISOLATE reorder rendered text without changing a byte, so a tool name carrying one could visually move the reserved governance suffix out of the position the composition guarantees. Unsafe Unicode categories (
Cc,Cf,Cs,Co,Cn,Zl,Zp) are now collapsed to spaces before any byte accounting, which also makes a lone surrogate — previously aUnicodeEncodeErrorinside the budgeting — a non-event. -
The verifier headline leads with the release blockers, not with the governance notice that outranked them. When a PR both touched the release trust root and blocked release on critical or high findings,
headline— the one line that reaches a PR comment, a chat reply, or a triage list — reported the trust-root fact and never mentioned the blockers. The two findings driving it aremedium; the blockers they outranked werecritical. Reproduced on google/adk-samples#1917, where fourSHIP-ACTION-FINANCIAL-WRITE-CONTROL-MISSINGblockers on an agent that batch-pays ETH and ERC-20 on Base mainnet went unnamed while the headline described Shipgate's own configuration (#365). The ranking now matches the severity: a run carrying a critical or high release blocker leads with the scan's own verdict line and appends the self-approval prohibition, so the human-review requirement survives in the same string rather than being replaced by it. That leading line also now names the worst blocker rather than only counting it — a count reads the same whether the agent is missing a docstring or can move funds with no enforced control — chosen deterministically by severity, then check id, then title, so two runs of the same tree name the same row. A trust-root or policy change with no blockers still leads with the governance notice — it is then the whole story. This is ordering only:control.state,must_stop,merge_verdict,can_merge_without_human,permissions,fix_taskrouting, and the release decision are untouched, and the reordered headline is now what the human-review route carries as its reason, so the control envelope cannot name less than the headline does. -
A first adoption no longer reports a policy weakening that could not have happened. The fail-safe that fires when there is no base policy to compare against shared a reason code with a proven base-relative weakening, so
verifier.jsonreportedSHIP-VERIFY-POLICY-WEAKENEDon a base that carried no gate at all — the condition every first adopter meets by definition. That fail-safe is now its own reason code,SHIP-VERIFY-POLICY-BASE-ABSENT(medium, floor medium, categoryverify), carrying both evidence kinds:manifest_introduced(git proves the base carries no manifest under any name) andbase_snapshot_unavailable(no base report was obtainable).SHIP-VERIFY-POLICY-WEAKENEDkeeps firing, unchanged, for every proven base-relative weakening — it is narrowed, not deprecated. Nothing about the gate moves with the reason code: same severity, same suppression immunity, samehuman_ackrequirement on thepolicysurface, sameprotected_surface_changesrows, same release decision. In particularverifier_summary.policy_weakenedkeeps its fail-safe meaning — only a git-proven adoption clears it, so a rename-and-loosen diff still cannot clear the gate-bypass alarm by breaking the base scan.Three compatibility rules make "same release decision" literally true rather than aspirational. Configuration written against the pre-split id still reaches the new one: a repository that had raised the no-base fail-safe with
checks.severity_overrides: {SHIP-VERIFY-POLICY-WEAKENED: critical}still getscriticaland still blocks, because the pre-split id is an umbrella over both halves (SPLIT_CHECK_ID_ALIASES) — distinct from the legacy-alias map, since the umbrella is not deprecated and a baseline naming it must not be flagged as stale. An override written against the new id wins, and floor validation is unchanged. Reports written before the split still reproject to what they meant: every read path — the verifier summary, the capability review,protected_surface_changes,human_ack, the adoption fix-task route, and the Action's findings fallback — accepts either id with the same evidence kinds, so a storedreport.jsoncarrying the old id withmanifest_introducedis still an adoption and still reportspolicy_weakened: false. Nothing re-emits the old id. Fail-closed routing and the human-facing claim are separated:capability_reviewgainspolicy_weakening_proven(additive, defaultfalse), the narrower fact that a base-vs-head comparison actually ran.policy_weakenedstill routes; onlypolicy_weakening_provenlicenses saying the policy was weakened. A no-base run now reads "This PR changes the release policy that evaluates it and no base policy was available to prove the change does not weaken the gate" instead of asserting a weakening nothing established — in the headline, the control reason, and the fix task's repair reason alike. -
The blocker title quoted into the headline is normalized and bounded.
ReleaseDecisionItem.titleembeds a tool name read out of an OpenAPI spec, an MCP export, or a Python source file, so quoting it verbatim made scanned input able to reshape the artifact that reports it: newlines survived into a field contracted to be one sentence, and length alone was enough to push the appendedcannot self-approveclause past the compact control envelope's 400-byte prose budget — deleting the human-review requirement from the projection a routing consumer reads. Control characters are now collapsed, the quoted title is capped, and the governance suffix gets a reserved byte budget so the lead is shortened instead of the requirement. If the requirement alone fills the budget it is published on its own, which is what the headline said before blockers ever led it. -
initranks agent-name candidates instead of taking the first one it trips over. Candidates were emitted in file-then-AST order with no scoring, and all three consumers — the manifest renderer, theinitJSON summary, and the zero-install detector — took the first entry with an acceptedsource. "First encountered" was the entire selection policy, and it produced two different wrong identities. In usestrix/strix it chose the one-character test literaltoverStrix(#320); in google/adk-samples#1745 it choseSalesforceAgent— a real worker agent — over theApp(root_agent=…)coordinator that declares it (#324). The second is the one that survives review: the manifest was schema-valid and named a genuine agent, just not the reviewed one. Selection is now one ranking pass over four signals — structural role (an application root outranks an unqualified agent, which outranks a declaredsub_agents=[…]/handoffs=[…]child), origin (product code outranks test code, which names fixtures), corroboration by the project name, and a quality floor that rejects values under three significant characters and generic scaffolding names. A rejected value is never written:agent.namekeeps itsCHANGE_MEplaceholder and the existingplaceholders[]review action, rather than asserting an identity nothing reliably declares.name=given as a symbol now resolves statically through one hop — a module constant or anos.environ.get("…", "…")default in the same package, never a chain, never a file outside the workspace, and never by importing user code. Eachagent_name_candidates[]entry carriesrole,path,rank_score,selectable, and arationale[]explaining its rank, so a future ordering regression is visible indetect --jsoninstead of silently changing what the manifest claims. The rule itself now exists once (select_agent_name) rather than as asource in {…}set literal copied into the renderer, the JSON summary, anddetect's human-readable line, andtools/shipgate-detect.py(script_version0.3.0) is pinned to the CLI's ranking byte for byte by the parity suite.Because the ranking reads Python name binding, it reads it the way Python does or else declines. Every binding is modelled, not just the ones that construct agents — a
root_agentlater rebound tobuild_root()retires the earlier construction instead of leaving it holding the role. Scopes are not flattened: a helper's localroot_agent, and a helper's local import, belong to that helper, and a free name in a nested function resolves against the enclosing function before the module rather than skipping the captured binding. A reference resolves to the binding that actually reaches it — nearest scope, latest line before the reference — and when any candidate sits under anif/try/loop the lookup fails closed, because both arms can execute and taking the lexically later one is a guess dressed as an answer. A symbol bound more than once anywhere in a file is never resolved, which is what makes reading module-level constants safe at all: a second write, whether later, conditional, computed, or in another scope, means the value Python passes is not the one visible statically.from config import AGENT_NAME as NAMElooks upAGENT_NAMEin the target module, not the alias. Anos.getenv/os.environ.getdefault is only read when the call provably resolves to the unshadowed stdlib import, so a module defining its owngetenv(key, fallback)cannot have its fallback lifted out as the agent identity. And when an import could resolve to two different in-workspace modules — the agent directory'sconfig.pyand the workspace root's — which one Python picks depends onsys.path, so the identity stays unresolved.Provenance is resolved, never assumed.
Agent/LlmAgent/Appare read through the binding that reaches them, so a constructor imported under an alias counts and one shadowed by a localdef/classdoes not — matching a terminal spelling let a decoydef Agent(...)supply a fabricated identity while the real aliased root went unseen. Every binding form is modelled, not just assignments:del,class,except … as,case, and aglobal/nonlocalstore routed to the scope it actually rebinds all retire the agent a name used to hold, and a file carryingfrom x import *can prove nothing about any of its names. Lookups into an enclosing or module scope no longer compare writes against the nested reference's line number: a function body does not execute where it is written, so a module-level rebinding below a nested reference still happens before the call, and only a single unconditional binding there is provable. AnApp(root_agent=Agent(…))built inside a branch is unresolved rather than whichever arm came first, andtests.py/test.pynow count as test code like every other conventional test module.Provenance is a question about a location, not about a file. The binding consulted is the one that reaches the call site, so a framework import at the bottom of a module no longer retroactively validates a decoy call above it, and a conditional import is not proof at all. Dotted spellings are held to the same standard as bare ones —
fake.Agent(...)cannot borrow the terminal name — and a constructor or stdlib lookup replaced through an attribute (adk.Agent = fake,os.getenv = fake) retires the provenance its import used to carry, since neither binds a name. A wildcard import suspends every spelling it could reach until a later explicit binding restores it.Scopes now follow Python's. Comprehensions have their own, so
[App for App in ()]no longer shadows a module-levelApp; definition headers — defaults, decorators, annotations, class bases and keywords — are walked in the enclosing scope, because that is where they are evaluated, which stops a parameter from shadowing the constructor its own default just used; and a scope introduced inside a branch carries that contingency into everything it declares, so twodef build()arms no longer resolve to whichever came first.Origin now dominates the score rather than competing with it. The documented contract is that product code outranks test code, but additive scoring let a test fixture that builds an
App(root_agent=…)outrank a plain agent the shipped code declares. The test penalty is now strictly greater than the whole spread of the hierarchy and corroboration signals, and a test pins that arithmetic so a future signal cannot silently widen the spread past it.A declared application root that cannot be resolved statically now blocks selection entirely. Dropping it and letting the rest of the field rank looks conservative but is the #324 failure again: everything remaining is by construction not the root, so the manifest would declare a worker. A dynamic name, a factory call, a symbol no single construction defines, a symbol that fails cross-module resolution, a conditionally assigned root, and a root rebound to a non-agent value all produce
CHANGE_MEplus the reason.The zero-install detector now takes the same workspace inventory as the CLI —
git ls-fileswhen Git can read the workspace, a contained filesystem walk otherwise — because that is what makes the parity claim true rather than merely tested on tidy fixtures. A.gitignored module is invisible toinit, so walking it anyway let the script name an agentinitwould never write; a symlink escaping the workspace both contributed a name and leaked its outside absolute path into the output. Two further detector fixes: a contained symlink keeps its logical path, because resolvingagent.py -> source.txtrenamed the entry, dropped the.pysuffix, and reported zero Python files where the CLI reported an agent project — the go/no-go verdict, not just the ranking. And the non-Git fallback walk now has a documented ceiling that raises rather than truncating, so a downloaded tree of unrelated assets cannot consume unbounded time and memory before detection sees a single source file.DetectResult(agent_name_candidates=[NameCandidate(...)])keeps working, for every sequence form the old field accepted — a tuple of instances used to raise and a tuple of legacy dicts used to land silently onselectable: false. Legacy entries are validated as aNameCandidatebefore being enriched, so a payload with missing values, wrong types, or keysextra="forbid"rejects is no longer upgraded into a well-formed lie.NameCandidateis a public export and was the declared element type before ranking existed; narrowing the annotation turned working calls into aValidationError, and a legacy dict parsed but silently landed onselectable: false, changing which nameinitwrites. Both are now upgraded at the model boundary using the rule that decided selection before this change, so old callers keep the behaviour they had.Its file bound also moved from the whole inventory onto Python parses, so an asset-heavy repository can no longer exhaust the budget before the walk reaches any source. Git's output is read incrementally against that bound rather than buffered whole and measured afterwards, and overrunning it exits non-zero exactly as canonical discovery raises — falling back to a walk would do the work the bound exists to refuse.
-
First adoption inside a monorepo no longer starts by writing the wrong manifest.
verify --previewrouted setup to the workspace root, so on a repository holding many self-contained agent projects the command it handed back produced a manifest covering all of them: ongoogle/adk-samplesthat was 252tool_sourcesand anagent.nametaken from the first of 160+Agent(name=…)literals, unrelated to the pull request under review. Nothing in the output said so — the JSON reportedmanifest_status: "written",is_agent_project: true,confidence: "high", and a higher framework score than the correct sub-directory — and the alignment layer that compares a declared purpose against the observed capability surface has nothing left to compare when one declaration covers many agents. The changed paths already answer the question, so preview now derives--workspacefrom them: each is attributed to the nearest directory at or above it carrying a project marker (pyproject.toml,package.json,go.mod,Cargo.toml, …), and the scope is that project when exactly one is claimed and every capability-bearing changed path was claimed by it. A capability path no project owns — a rootprompts/system.mdtravelling with a change toservices/b— vetoes the answer rather than being dropped, because "this change belongs toservices/b" would then be a false statement about the diff. The workspace root is likewise left unchanged whenever the paths do not narrow it — a root-level change, or one spanning two projects, which narrows to nothing rather than to their common parent, because the parent of two projects is not itself a project. Documentation and tests cannot outvote code: when more than one project is claimed, the projects claimed only by documentation or test paths drop out, and the trigger catalog's own docs-only rule decides which paths those are so the two surfaces cannot drift. That leg is load-bearing — the reported PR editspython/agents/README.mdone directory above the project it adds, and counting that README as a competing claim sends the answer straight back to the repository root. Base detection now runs in preview exactly as it does inverify(honoring--no-base), because the promoted adoption command is the bareverify --preview --json: without it the preview every first adoption runs evaluates an empty change set and nothing below it can fire. Markers are read from the working tree — whatinitwill run against — so a preview of a head that is not the current checkout claims no scope at all rather than recommending a directory that depends on what happens to be checked out. When the changed project already carries its ownshipgate.yaml, preview routes toverifythere, ahead of any root manifest, which governs a different boundary; that also stops the next preview from looping against a manifest that now exists. A change spanning several projects routes todetectinstead of to aninitthat would deterministically refuse.init --writecloses the same gap from the other side: a workspace whose manifest scope is unresolved reportsmanifest_status: "refused_unresolved_scope"(exit 2) withauto_detected.agent_scopeandagent_project_candidates[], rather than adopting the first agent name it parsed. Two things make a scope unresolved.agent_scope: "ambiguous"is agents in several self-contained projects — evidenced by everythinginitwould turn into a manifest, so an OpenAPI-only or MCP-only project with no Python at all counts, as does a nestedshipgate.yamlsomebody already scoped by hand.agent_scope: "unknown"is discovery capped before it could tell: Python parsing stops at--max-python-files, and in a workspace with several project roots a "single project" verdict would just be whichever files were read first. Truncation alone is not enough to withhold an answer — a repository with one project root has nowhere for a second project to hide, so large single-project repositories keep their verdict and their workinginit. A refused run writes nothing — not the manifest, not the CI workflow, not the agent-instruction snippets, not the reports.gitignoreblock — so a workspace Shipgate declined to adopt carries no Shipgate edits into the diff. Rank 1 of the emitted recovery is deliberately not a command: promoting one candidate would make the same arbitrary pick the refusal exists to prevent, and the refused workspace itself is never offered back. The per-candidate commands repeat the setup flags the caller passed, so a recovery cannot silently complete with less than--cior an agent-instruction selection asked for.--minimaladopts no detected name or tool surface and is unaffected; a repository that really is one agent surface across several projects passes--allow-unresolved-scope; and re-runninginit --write --agent-instructions=…on an adopted repository is untouched, because its scope was settled when its manifest was written.Two surfaces around it were wrong in the same direction.
init --ciscoped to a sub-directory wrote<project>/.github/workflows/agents-shipgate.yml, which GitHub never loads — a gate reported as written that could not run; the workflow now lands at the repository root with aconfig:naming the manifest relative to that root. AndTRIGGER-SHIPGATE-MANIFESTmatchedshipgate.yamlonly at the repository root, so an edit toservices/refund/shipgate.yaml— the file that declares that project's agent, purpose, and tool surface — reportedno_match; it now matches at any depth, as do the pre-commitfiles:regex and the Cursor activation globs that copy the catalog, and a nested manifest counts as the repo-already-opted-in signal.tools/shipgate-detect.py(script_version0.3.0) carriesagent_scopeandagent_project_candidates[]too, pinned against the CLI by the parity test: an agent that consults the zero-install path must not adopt a scope the CLI refuses.Review found the routing could still be spent on evidence that did not support it, so three things changed shape. Preview now evaluates the same effective change set the full verifier does — committed range unioned with uncommitted and untracked work — because the command it emits runs against the working tree: an uncommitted-only capability change previously read as an empty diff, and an empty diff read as "nothing narrows the scope". "Scope not established" is now a state of its own rather than an absence: a head that is not checked out, a capability path no project claims, or a change spanning several projects each route to discovery or to human review, and never to initializing the repository root, which would turn "Shipgate could not tell" into a manifest for whichever agent the current checkout happens to hold. When the contested projects are already configured, each is its own gate and a human decides — a root manifest is not a substitute for either boundary.
Three more surfaces followed the same distinction. Reports now default to the workspace the caller named, so
verify --workspace apps/awritesapps/a/agents-shipgate-reports/— the directory that project's managed.gitignoreblock actually covers, and one that two projects cannot overwrite for each other; an explicit--outstill resolves against the repository root.--ciwrites one workflow per gated manifest (agents-shipgate-<project>.ymlbeside the root'sagents-shipgate.yml), because the action takes a singleconfigscalar and one shared file gated whichever project initialized first while reporting a skip for the rest; that scalar is now YAML-quoted when the path needs it, so a directory namedapps/agent #1no longer renders a comment. And thetriggercommand reads the nested-manifest opt-in through the same resolver preview uses, soapps/a/README.mdbesideapps/a/shipgate.yamlstops reporting a docs-only skip.Detection got two corrections in opposite directions. A bare
requirements.txtstill is not a project boundary, but one sitting beside an agent is the only boundary that layout has — two sibling ADK agents were reported as one root scope, andinitpicked one of their names. And anAgent(name=…)literal only draws a boundary when its file carries a supported framework import: an unrelated module defining its ownAgentclass made a single-agent repository refuse. Those literals remain name suggestions.tools/shipgate-detect.pykeeps its marker census complete rather than truncating it with the general file cap, so heavy filler no longer hides the projects it is supposed to find. (#363) -
One control vocabulary across the adoption walk.
detect,init, anddoctoreach answered "what do I do next" in their own shape, so an agent driving a first adoption had to learn four result formats and could not tell a setup obligation from a gate verdict. All three--jsonpayloads now carry acontrolfield holding the sameshipgate.agent_control/v1envelope thatverify --format control,check --format agent-control-json, andagents-shipgate agent controlalready emit — onecontrol_state, one six-waypermissionsvector, one typed rank-1next_action. It is a projection of the diagnostics those commands already publish, computed in one module, socontrol.next_actionandnext_actions[0]name the same work by construction; no renderer computes a second verdict. Contract24. Every existing field, includingnext_actionandnext_actions[], is unchanged. Setup and gate control cannot be confused: setup reportsdecision_source: "setup"with a verdict fromsetup_complete | setup_incomplete | setup_not_applicable, and the published schema requires that source to come fromdetect/init/doctorand requires those operations to report no other source. Setup also authorizes nothing — it reads no diff, so all six permissions are false, it binds no artifact or control identity, andcontrol_state: "complete"is unreachable for these operations in the schema itself, because a successfulinitis not permission to commit, merge, or report a task done. -
A manifest declaration a person owes is no longer routed to the agent.
init --writereported unresolvedCHANGE_MEplaceholders and, in the same breath, told the caller to scan — inviting the agent to invent anagent.declared_purpose, which is exactly the class of claimdo_not_auto_assertexists to protect. When a human-owned placeholder is unresolved, the setup control state is nowhuman_review_required, the action names the exact file, line, and field, andnext_action/next_actions[]carry that same route — publishing the control state beside an unchanged executablescancommand would have left the unsafe answer exactly where a pre-#323 consumer reads it. Ownership covers everydo_not_auto_assertsurface with a manifest spelling,agent_bindingsandaction_surfaceincluded, not onlydeclared_purpose. A placeholder an agent can legitimately resolve from the repository — a tool-source path, a project name — stays coding-agent work, and once the human-owned values are supplieddoctoradvances deterministically toverify.init --write --agent-instructions=…over an existing manifest now inspects that manifest rather than the template it did not write, so the documented refresh command is not a route around the boundary. -
The
AgentControlunion is unchanged, and the compatibility floor stays at21. That union is embedded by the verifier, the handoff, preflight, the agent result, the boundary result, and verify-run, so widening it would widen six durable published schemas under unchanged identifiers — and five of those artifacts record nocontract_version, so a consumer holding a stored payload could not use the floor to tell which shape it has. A setup step that needs a file changed is still typed: the envelope publishesnext_action.kind: "edit"withpathandexpects, asSetupEditAction— declared on the envelope, which is emitted on stdout and never stored, and rejected in both layers on any operation butdetect/init/doctor. Routing such a step as the command that merely checks the edit was tried and is wrong: an envelope-only consumer executing it re-randoctoragainst an unchanged file forever, with the instruction surviving only inwhy. -
A completion cannot rest on a negative verdict. Constraining each
decision_sourceto its own vocabulary left the verdict free of the authority it sits beside, so acompleteenvelope withpermissions.merge: trueaccepteddecision: "blocked"— a schema-valid negative gate result granting terminal authority. A completed release result now admits onlypassed, and a completed boundary result only the non-blockingallow/warn, in both layers. -
A manifest that is not UTF-8 is refused, not rewritten.
errors="replace"turned one0xffbyte inproject.nameinto U+FFFD, sodoctorloaded a different, valid manifest, reportedsetup_complete, and recommended verify — whilescanon the same file exited 4. Setup and the gate now validate the same input language. -
The envelope only calls a string a command when something can run it. A diagnostic's remediation is an instruction for a reader as often as it is an invocation: the unknown-adapter routes read
AGENTS_SHIPGATE_ENABLE_PLUGINS=1 agents-shipgate scan …, a shell assignmentshlex.splitturns into a program literally namedAGENTS_SHIPGATE_ENABLE_PLUGINS=1, andpip install <third-party-adapter-package>, a placeholder nobody can install. Both were promoted verbatim intocontrol.next_action.commandunderagent_action_required— a route whose single step cannot be taken. Such a remediation now routes to a human, carrying the string as prose. This stops at the envelope:next_actions[]keeps the diagnostic's own action, becauseNextActionalready withholds its computedexecutable/argspair for a string with no faithful argv while letting the rendered string stand, and the envelope has no equivalent way to publish an instruction that is not argv. -
control.next_action.pathnames the file byte for byte. The envelope's text type normalizes what it validates, which is safe for prose and not for a path: a filename may legally begin or end with a space, so' manifest.yaml 'in a diagnostic became'manifest.yaml'in the envelope and the two rank-1 projections pointed at two files. The prose cap now covers the setup edit'swhyas well, which had routed around it and published 1,134 bytes on a contract that documents 400. -
Every
doctor --jsonpayload carries the route, including the earliest failure. A--configglob that matched nothing raised before the projection and returned the legacy error shape alone, with nocontrol,decision_source, orinput_id— the one counterexample to the promise this rollout rests on. That branch now projects a denied setup envelope, and the failure identity covers the route it publishes rather than only the defect that caused it, so the same rejected workspace read through two entry points no longer answers under oneinput_id. -
A dry run that wrote the CI workflow no longer says nothing was written.
--ciis orthogonal to--write, soinit --ci --jsonreportedworkflow.status: "written"a few fields above awhyclaiming otherwise. -
scanis outside this rollout, and now says so.agent controlafter a scan reportsdecision: nullwith areasonstating the verdict is withheld, not absent: the scan reached one —report.sarifeven carries it — but a scan pointer binds no reconfirmable snapshot of the inputs it read, so no artifact in that directory can show the verdict still describes the workspace. An edit to the manifest, a referencedtools.json, a policy pack, or a baseline leaves the pointer reading cleanly with the old answer. Publishing a verdict fromscanneeds a complete input snapshot threaded through report generation and pointer publication; until that lands,verifyis where a checkable verdict comes from, and #323's scan half stays open. -
The recommended next command now runs where it was recommended. Every emitted command was written as the console script the wheel installs (
agents-shipgate …), so a run started from a source checkout withpython -m agents_shipgatehanded the caller a command its environment may have had no wrapper for — and agent-mode errors reported the running command as__main__.py, which is not a program and which any consumer rendering the field as Markdown silently corrupts to**main**.py. Commands are now spelled for the entry point that started the process: a console-script run emits exactly what it emitted before, apython -m agents_shipgaterun emits<sys.executable> -m agents_shipgate …(by interpreter path, since a barepythonresolves throughPATHand can land on a different one), andAGENTS_SHIPGATE_CLI— already the operator override for the Claude Code hook command — takes precedence over both, including when the path it names happens to end in a console-script name, and parsed with the host's own rules so a Windows value keeps its backslashes. One policy covers preview, init, doctor, scan, verify, detect, check, preflight, apply-patches, and the control and repair commands the verifier and boundary publish; it is applied at the emission boundary, so a route built as a plain dict rather than as aNextActioncannot opt out. Contract23.next_actions[]entries withkind="command"additionally carry anexecutable[]/args[]pair — the authoritative runnable form on every platform, needing no shell. It is computed fromcommandrather than stored, so supplying it is an error and no mutation can leave it describing a command the action no longer holds; the pair is omitted rather than emitted asnull, so every action that cannot carry an argv is unchanged on the wire, and it is withheld entirely when the command needs a shell — an operator, a redirection, a substitution (including inside double quotes, which do not make$VARinert), or a<placeholder>— rather than advertising an argv that would do something else.commanditself is a POSIX rendering on every platform: one renderer and one parser must agree, and pairing Windows argv-quoting with a POSIX parse turnedC:\repointoC:repo— a runnable command against the wrong workspace. Because that rendering is uniform,shlex.split(command)recovers the exact argv on every surface and host, which is the documented recovery for the operational control contracts (control.next_action,allowed_next_commands, verifier repairs), where the argv pair is not carried. When the rank-1 action is a command, the legacynext_actionstring is that command verbatim, so the back-compat field cannot route a caller to a different program thannext_actions[0]. Durable evidence stays canonical:report.json,report.md, andpacket.*are byte-identical however the process was started, because "same inputs, same report" outranks runnability there and process entry is not an input. This is the path used to evaluate external PRs, where the recovery loop breaking is worst. (#322) -
A prompt or policy edit outside the repository root — or spelled
Policies/— no longer reports as "nothing in this PR signals a tool-surface change."TRIGGER-PROMPTS-OR-POLICIESmatchedprompts/**andpolicies/**only at the root, and the trigger evaluator matched globs case-sensitively while the verifier's trust-root classification reads the same two surfaces at any depth (**/prompts/**,**/policies/**) and tolerates the case variant a case-insensitive filesystem resolves to the canonical name. The two lists disagreed about the same paths: a PR touchingservices/foo/policies/refund.yamlreachedskip_reason: "no_match"for a pathSHIP-VERIFY-POLICY-WEAKENEDtreats as a policy trust root, andservices/foo/Policies/refund.yamldid the same after the recursive fix alone. Prompts failed worse than that — a nestedprompts/*.mdedit satisfies the docs-only negative rule's**/*.mdleg, so it did not merely fail to match, it actively skipped. Both globs now match at any depth, in the positive rule and inTRIGGER-DOCS-ONLY-NEGATIVE'snone_match_globlist, so a nested prompt edit bundled with a docs edit is no longer classified as docs-only; and the routing predicates —globandnone_match_glob— now use the same case-tolerant matcher as the trust-root classifier and the boundary registry.every_file_matchesdeliberately does not: it is the docs-only rule's own classifier, so folding it would subtract evaluation rather than add it. The catalog'spredicate_vocabularydocuments both sides of that split; see the directional rule below.The surfaces that copy this routing follow, so the fix is end-to-end rather than evaluator-only. The pre-commit
files:regex now matchesprompts/,policies/,.codex-plugin/,.agents/plugins/,.n8n/andAGENTS.md/CLAUDE.mdat any depth, covers the n8n and Conductor path legs it silently omitted, and matches a tracked path named exactlydirfor adir/**glob — its "covers every path-based trigger" claim is now pinned exhaustively, so a catalog rule with a path leg must be listed in the hook fixture table or named in its exclusion set. Both documented copy-paste hook snippets are derived from the canonical regex and pinned clause-by-clause rather than by a hand-maintained sample. The.cursor/rules/agents-shipgate.mdcactivation globs gain the recursive forms too: that rule isalwaysApply: false, so until a glob matched, a lone nested governance edit activated no Shipgate instructions at all — and the benchmark setup variant and the adoption harness's lint constant, two copies of that list that nothing enforced, are now pinned to the renderer.A new parity test pins every governance trust-root surface — the manifest,
.agents-shipgate/,policies/,prompts/, and the Shipgate CI workflow — to a representative repo-root path, a nested one, and a case variant, so the trust-root list and the trigger catalog cannot drift apart again unnoticed. It records that a nestedshipgate.yamland a nested.agents-shipgate/are still routed at the root only, because those two are anchored there by the boundary registry that check, verify, preflight and audit share rather than by the catalog. The catalog stays atschema_version0.3: this widens an existing rule's globs, adds no rule ID and no state, and moves outcomes only toward evaluating more, never less.Case tolerance is chosen per predicate by which way a wider match moves the verdict, not for uniformity.
globandnone_match_globare folded because a wider match can only add a run or make a negative rule fire less.every_file_matchesstays case-sensitive: it is the docs-only rule's own classifier andskip_shipgatebeatsrun_shipgate, so folding it would readsrc/TEST_agent.py— a production module on a case-sensitive filesystem — as a test file and skip a PR that adds@function_toolbeside it. The same tolerance now reaches the Tier B checks:_verify_common.touched()selected changed files case-sensitively, soservices/foo/Policies/refund.yamlwas a policy trust root that produced no fail-safe finding, and a deleted.github/workflows/Agents-Shipgate.yamlwas aci_gatetrust root that missed the critical gate-removal finding entirely. Preflight's three inline copies of the same retry are folded into the one helper.Both pre-commit hooks now declare
types: []withtypes_or: [file, symlink]. pre-commit's defaulttypes: [file]is an AND-filter applied beforefiles:, and a tracked symlink carries thesymlinktag rather thanfile— so a governance directory symlinked into a workspace invoked neither hook no matter what the regex said. -
The
on-tool-source-changesCI recipes are retired, on GitHub Actions, GitLab CI and CircleCI alike. They gated Shipgate behind a changed-path allowlist, which cannot work for two independent reasons. First,TRIGGER-EXISTING-MANIFEST-PRESENTisforce_run: a repo with ashipgate.yamlis contracted to run on every PR, so the prefilter never saved the scan it advertised. Second, every prefilter language involved — GitHubpaths/paths-ignore, GitLabrules.changes, a CircleCI shell diff-gate — matches case-sensitively, while the trigger catalog matches governance paths case-insensitively on purpose; an allowlist therefore dropsservices/foo/Policies/refund.yamlwith no job, no check and no signal at all, which is indistinguishable from a repo that never adopted the gate. Run the advisory recipe on every PR and let the in-job trigger evaluator decide;verifyshort-circuits before the scan on a skip verdict. A contract test now rejects any of those prefilter forms in any shipped recipe, across both.ymland.yaml. -
One compact object now answers "what may I do next?", instead of four artifacts and a guess. A verify run could simultaneously report
execution: "succeeded", exit code0,release_decision.decision: "review_required", andcontrol.state: "human_review_required"— four facts, three of which read like permission to continue, spread across thousands of tokens of forensic JSON. Newshipgate.agent_control/v1(docs/agent-control-schema.v1.json) carries tool execution status, the release or boundary decision and which engine produced it, the control state, the six-waypermissionsvector, the next actor, the exact next action, the identity of the input it was assessed against, any review obligations still owed, and the path and sha256 of every artifactcurrent-control.jsonbinds — in one stdout object under a publishedagent_control_budget_bytesbudget of 4096 — a measured target, not an enforced cap, since a long reviewer list or exact command must never be truncated to fit — roughly a fifth of theverifier.jsonplusagent-handoff.jsonan agent reads today to answer the same question. Three separations that were documentary are now structural: a failed execution can never authorize completion (and a succeeded one implies nothing); a stopping state authorizes nothing; andpermissions.merge, notexit_code, answers "may I merge" — the exit code is the CI gate signal and in advisory mode ablockeddecision still exits 0, which is now pinned across all four decisions in both modes. The envelope decides nothing: every field is copied from a producer that already published it, and where the current-control pointer refused a completion its run claimed, the pointer wins and the run's route is dropped rather than recovered. Emitted byagents-shipgate verify --format control(added;--jsonstill emits the full verifier artifact),agents-shipgate check --format agent-control-json(added;agent-boundary-jsonunchanged), andagents-shipgate agent control, whose default output changes from the raw pointer to the envelope —--format pointerreturns the previous output unchanged.verify --format textnow leads with the control state, next actor, and permission vector before the existing verdict line. Both entry points run one currency test:verify --format controlvalidates its own published pointer against the live workspace and withholds authority when the workspace moved past what the run evaluated, instead of reportingcompleteon a directoryagent controlwas simultaneously refusing, and it routes from the verifier bytes captured inside that read so a pointer can never be paired with another generation's decision. Emitted artifact paths are relative to the invoking directory and joined structurally, so a root of/or a trailing space cannot rename the file whose hash was validated.input_idbinds compact authority to the input it assessed — required oncomplete, since two unrelated diffs otherwise projected byte-identical envelopes granting merge — andpending_review[]carries obligations a non-terminal route still owes. Human-readable output renders control characters visibly and keeps one field per line, closing a spoof where a workspace path containing newlines printed forgedControl: completeandYou may: ... mergelines; JSON keeps the exact bytes. A current-but-routeless generation (ascanpointer) is now reported with exit 0 and merge denied instead of refused, preserving the documented meaning of a non-zero exit, and recovery commands are generated from the requested workspace and reports directory rather than a hardcoded default. Terminal authority is additionally constrained by provenance —completeis representable only fromverify(naming its pointer and artifacts) or fromcheck(naming neither), never fromscanorpreview— and averifyroute cannot dropverify_required; both are published in the JSON Schema, not only enforced in Python.verify --format controlnow reports only this invocation's generation instead of whichever is current, and the currency comparison re-observes the workspace after confirming the pointer, closing a window in which a commit could land mid-read. Separately,.shipgate/agent-contract.jsonnow upgrades in place from any superseded managed version rather than only from renders whose exact hash was recorded — repositories on local-contract schema 8 or 9 were stranded. Runtime contract advances21 → 22and the downstream local contract9 → 10;minimum_control_contract_versionstays at21because theAgentControlunion itself is unchanged. (#333, #323, #338) -
The release pipeline now proves the wheel it publishes came from the tagged commit. The tag workflow established three bindings — tag to
pyproject.tomlversion, qualification payload to wheel bytes, and tag to the wheel's ownMETADATAversion — but none tied the shipped bytes back to any source tree. It tested the checkout withruff,compileall, andpytest, then published a wheel downloaded from a repository-variable URL, with nothing asserting the two corresponded: any wheel declaringName: agents-shipgateand the rightVersionsatisfied every check. Verification now rebuilds the wheel from the tagged checkout and requires byte equality with the qualified wheel before publication (scripts/verify_wheel_provenance.py). Byte equality is achievable because the build backend is pinned inconstraints/release-build.txt— wheels recordGenerator: hatchling <version>, so an unpinned backend alone changes the archive. A container-metadata-only difference is reported as a reproducibility gap and still fails; the weaker unpacked-content bar exists behind an explicit--allow-payload-equivalentflag so it can never be taken silently. The published artifact is still the signed, qualified wheel; the rebuilt one is only a comparison reference. -
Verification and publication are now separate jobs, and a partial publish is recoverable. Expensive verification and immutable publication ran in one job, so a failure after a successful PyPI upload could leave an immutable version with no finalized GitHub Release and no attached provenance — and re-running was not a safe recovery, because the version already existed. Verification is now a read-only reusable workflow (
contents: read, no OIDC) that hands off a content-addressed candidate bundle; the manifest digest travels through the job-output channel, so swapping an artifact — or rewriting the manifest to agree with the swap — is detected, and the check is closed-world so an unlisted file cannot ride along. Write and OIDC authority are never held by the same job: the PyPI publisher holdsid-token: writealone, checks out no project code, and installs only a hash-locked toolchain, so a compromised dependency cannot both mint a token and rewrite the repository. A draft GitHub Release carrying the authoritative assets exists before the upload, and finalization happens only after asset validation. The upload is idempotence-aware:scripts/release_publication.py pypi-stateclassifies the index asabsent,published_identical— requiring exactly one unyanked wheel with the expected filename and digest, so a divergent sdist or second wheel is not mistaken for a completed transaction — orpublished_divergent(always fatal). An unreachable index is never read as permission to upload, and an already-published release is verified and left untouched rather than clobbered. Release concurrency is serialized across the PyPI project rather than per tag, withcancel-in-progress: false. Thepypireviewer gate moved to publication, so reviewers approve after the readiness summary exists instead of approving a run whose evidence has not been produced yet. -
The qualification signer identity is reviewed code, and a release candidate must have been rehearsed. The identity and OIDC issuer that authenticate the signed qualification artifact now live in
.github/release-trust-roots.jsonrather than in variables: an actor able to set variables could otherwise substitute fabricated evidence and replace the allowlist that vouches for it in one unreviewed step — an attack source-to-wheel binding cannot see, because it reuses the legitimate wheel and forges only the claims about it. Only content-addressed locations stay mutable. Verification also runs against the immutable event SHA rather than the symbolic tag ref, and the tag is re-peeled against the remote before each irreversible step, so a moved tag cannot make the pipeline build one commit while claiming another. Publication additionally requires a successful rehearsal at the same source SHA whose candidate manifest is byte-identical, and every rehearsal now proves the provenance gate fails closed by injecting a tampered wheel and asserting it is rejected. -
The signed SBOM now describes the shipped wheel instead of the CI machine. The workflow installed
.[dev]and rancyclonedx-py environment, inventorying pytest, ruff, twine, Sigstore, and the CycloneDX tooling itself — a signed attestation about software the user never receives.scripts/release_sbom.pyinventories an isolated, runtime-only install of the qualified wheel, binds the document to that wheel's SHA-256, and re-verifies the binding before publication. It also normalizes away thefile://build-machine path CycloneDX records, which otherwise leaked runner filesystem layout into a published artifact and made the signed SBOM non-deterministic. The dev-only exclusion is derived from thedevextra rather than hardcoded, so new tooling is covered automatically. -
A release candidate can be rehearsed without any publication authority. The workflow could only be exercised by pushing a
v*tag, so its verification and failure paths were first-run at the moment publication became possible — steps added after v0.15.0 had never executed. Aworkflow_dispatchrehearsal now runs the identical build, qualification, test, audit, SBOM, and handoff path by calling the same reusable workflow, and is structurally incapable of publishing: no publication job exists in the file,permissions: contents: readcaps the token so tag and release creation fail, and noid-token: writemeans Trusted Publishing cannot mint a token. Rehearsal is a documented prerequisite for a candidate tag. -
Release test selection matches CI, so candidates fail on correctness evidence rather than timing noise. The release ran the full suite serially, including timing-sensitive
perftests, inside a 15-minute budget shared with qualification, audit, signing, and artifact work. It now uses CI's-n autoparallelism and excludesperf-marked latency budgets, which remain enforced at merge time; the adapter static-only trust-model lint keeps its own fail-fast step, and the coverage floor stays at CI's 85. The timeout is derived from measurement rather than estimate, with the basis recorded indocs/release-runbook.md. -
The release page carries the changelog, and the release runs the environment CI approved. Two loose ends from the release-workflow review. The GitHub Release body was the placeholder
Agents Shipgate <tag>whileCHANGELOG.mdheld the entry describing what actually shipped, so the one artifact users read said nothing;scripts/release_notes.pynow extracts the section matching the tag and publishes it through--notes-file, verbatim and from the checkout pinned to the verified commit rather than retyped at tag time. A missing section fails verification, which the rehearsal also runs, so it is caught while the tag does not yet exist — and## Unreleasednever matches a tag, which is what makes promoting that heading a step the pipeline enforces. A body over GitHub's 125,000-character limit is refused there too, rather than by a 422 after tagging. Verification, staging and finalisation each extract the section, so verification publishes its SHA-256 and the other two must land on it; finalisation reapplies the body in the same API call that undrafts, because the window between staging and publication — the environment approval included — is time in which a release-write actor can edit a draft's text that nothing downstream re-reads. Every job writes the file under$RUNNER_TEMP, so a candidate cannot decide where the write lands. Separately,pip install -e ".[dev]"resolved fresh at release time, so the run that decided whether to publish could install different packages than the CI run that approved the commit, and a release-only failure was not reproducible from the same tree. CI and release verification now install the identical hash-locked closure inconstraints/dev.txt, add the project with--no-deps(an editable install cannot be hashed) and--no-build-isolationagainst the hashed backend closure inconstraints/build-backend.txt—--no-depsdoes not disable PEP 517 build isolation, and current pip does not applyPIP_CONSTRAINTto an isolated build environment, so the backend and its own dependencies were still being resolved from the index on every run — and finish withpython -m pip checkproving the closure satisfies what the project declares. Regeneration is one command for every lock in the repository (scripts/update_locks.py, which restores the headersuvwould overwrite), and each lock now records the normalized PEP 508 declarations it was compiled from, so a declaration that grows an extra, moves behind a marker or becomes a direct URL invalidates it even though every name and range still matches.scripts/verify_dependency_lock.py— run in CI and before publication — checks that binding plus a declared requirement with no pin, a pin outside the declared range, a direct requirement the declarations no longer contain, a pin without a hash, and locks installed together that disagree. Markers are compared by evaluating them over the environments the project supports (CPython 3.12–3.14 × linux/darwin/win32 × x86-64/aarch64) rather than as text, so a conditional declaration that is genuinely missing is distinguished from one no supported environment selects, a pin whose own marker excludes the platform that needs it is caught, and a valid universal fork is not mistaken for a conflict.[build-system]is bound to the same closure, so a raised backend floor or a switch to another backend can no longer leave every file consistent and the wheel built by something nobody pinned. CI builds the package with--no-isolationfor the same reason. It never re-resolves against the index, so an unrelated upload cannot turn the build red. Two release-only defects found in review are fixed here as well:publishandfinalizeused a local composite action without checking the repository out, which fails while preparing the action — the first release to reach publication would have broken there, so both now check out that one action directory sparsely, at the verified commit, with cone mode off; and the publication allowlist matched onlyname==versionlines, so aname @ URLrequirement was installed in the token-bearing jobs without ever being compared against it — every requirement form it cannot review is now refused, and the check runs as its own step against crafted lockfiles in the suite. (#345) -
Insufficient-evidence remediation now stays framework-aware from the decision engine through every primary short-form surface. Semantic
incomplete_surfacegaps for frameworks with explicit inventory support now lead with the generatedsuggested-inventory.jsonartifact and the exact<framework>.tool_inventoriesmanifest key instead of an unreachable generic MCP/OpenAPI route. Console scan output, the GitHub step summary, and text-modeverifyall project that same rank-1 action; unsupported source shapes retain the compatibility fallback. Human work now precedes the exact rerun command in text output, and verifier fix tasks collapse the duplicate semantic and extraction inventory remedies into one instruction. The regression runs a real Google ADK workspace through static extraction and semantic assessment, so it cannot manufacture a pass-ineligible medium-confidence tool state. (#318) -
Human review now blocks merge and completion, not publication of the evidence a human needs in order to review. A human route was one universal stop:
control.state: "human_review_required"withmust_stop: trueandallowed_next_commands: []. For an agent working on a pull request that denied commit, push, and PR updates — the exact actions required to produce a reviewable diff — so the workflow was circular: review was required, and the agent could not publish the state to be reviewed. Two additive changes fix it.control.permissionsis a new object carrying the exact booleansedit,commit,push,update_pr,merge,report_complete; it is fixed by the state and the route, never set independently, andmerge/report_completealways equalcompletion_allowed, so human review never becomes self-approvable. A route that runs before any diff was read (fetch_base,install) authorizes none of the six. A fourth state,review_publishable, means "a human must approve the merge, and the agent may still publish the change for that review":must_stop: false, a humannext_action, and at most the one exact rerun command that regenerates the same evidence against the committed refs.human_review_requiredkeeps its exact old meaning and is now reserved for results Shipgate cannot vouch for — a blocked release decision, ablockboundary decision, a run whose execution failed, unreadable or unbindable diff input, an undeclared capability surface with no discovery route, preflight protected-surface touches, and MCP audit blocks. Runtime contract advances20 → 21andminimum_control_contract_version14 → 21; consumers that switch oncontrol.statemust add areview_publishablebranch and keep failing closed on unrecognized states, while consumers that read onlymust_stopandcompletion_allowedneed no change and lose no safety. Every schema that carries a control advances its identifier and freezes the prior file — verifier0.7 → 0.8, handoffv6 → v7, verify-runv3 → v4, shared agent resultagent_result_v2 → v3, agent boundary resultv1 → v2, preflight0.3 → 0.4, downstream local contract7 → 8— becausepermissionsis a new property on variants published asadditionalProperties: false, and leaving the identifier in place would have made one version name mean two incompatible shapes. CLI flag spellings are unchanged, andaudit_iddoes not rotate: the schema token it hashes is pinned to the value established ids were issued under.shipgate.codex_boundary_result/v2stays frozen and now carries its own snapshotted control union instead of inheriting the live one. Publication additionally requires a replayable subject, fully-read input, and a succeeded non-blocked release decision — enforced in Pydantic and in generated JSON Schema — so a detached diff, a partially unparsed MCP audit, or a failed run can never authorize it. Legacy artifacts and the frozenshipgate.codex_boundary_result/v2projection are unaffected: pre-v20 payloads normalize tohuman_review_required, and the frozen format omitscontrol.permissionsand renders the new state ashuman_review_requiredwithmust_stop: true. The installed Claude Code Stop hook now says, on a publishable review, that commit/push/PR-update remain authorized and names the rerun command. (#335) -
Local verification now evaluates committed and uncommitted edits as one effective worktree diff. When a branch change and a review follow-up touch the same path,
verifycompares the merge base directly with the current worktree instead of concatenating overlapping diff records. The evaluated change set is merge-base-relative while the overlay is HEAD-relative, so the verification plan binds the exact HEAD-relative overlay path set separately — a path canceled by an uncommitted edit leaves policy evaluation but stays bound, at its real content, in the receipt. Canceled committed changes are called out inbase_notes; worktree diff collection honors repositories that setcore.fileMode=falserather than forcing Git's mode reads on, which had turned every tracked file in such a checkout into a phantom mode change; and a plan that predates the bound overlay path set fails with an explicit re-prepare action. (#336) -
A coding agent can no longer enforce a verifier result the workspace has outgrown. The reported failure ran forward: a worktree verify returned
human_review_required, a human committed the reviewed change, a fresh committed-ref run produced acompletereceipt for the same request — and the agent kept enforcing the oldermust_stop, asking for the commit that had already happened. It runs backward just as easily: acompleteremembered from earlier in a conversation is not evidence about a workspace that has since been rebased, checked out, or reconfigured. The content-addressed receipt already prevented an old decision from authorizing a different request; what was missing was one atomic place to ask "what is current now?", and any obligation to ask it. Both are now present.agents-shipgate-reports/current-control.json(shipgate.current_control/v1) is that entry point. It is a pointer, not a second decision engine: it binds identities and hashes of the receipt, handoff, verifier, and report those commands already publish. Its lifecycle is what makes it trustworthy.verify,verify --preview,scan, andverification prepareeach replace it with a non-terminalunavailablemarker before touching any other artifact, so a run that crashes leaves a directory that denies cached control instead of one that still advertises the previous verdict for a workspace that has moved; the terminal pointer is written last, after every artifact it references exists and has been hashed, and published by same-directoryos.replaceso no reader can observe a half-written one. Readers use the generation-safe protocol inagents-shipgate agent control: validate the pointer, validate every artifact hash it binds, re-read the pointer, and continue only ifcurrent_control_idis unchanged — a run that republishes mid-read makes the read fail rather than return one generation's pointer beside another's artifacts. And because byte consistency is not generation consistency — every bound artifact still hashes correctly one unrelated commit later — the read also compares the pointer'sworkspace_identityagainst the live repository: repository, HEAD commit, and HEAD tree, plus the base revision when the decision named one — advancing a base untilbase...HEADis empty changes the evidence completely while leaving HEAD and the working tree untouched. Uncommitted work is checked against what the decision actually covered: a worktree decision must still hash to the overlay it committed to and see no live change outside the set it recorded, while a committed-tree decision, whose evidence stops at HEAD, is invalidated by any uncommitted change that appeared afterwards. Overlay rows bind entry kind and the executable bit alongside content, so a100755→100644flip or a regular-file-to-symlink swap with identical bytes cannot pass as unchanged. Completion authority is never returned without that comparison. Two invariants are structural rather than advisory: only anoperation: "verify"pointer can carrycontrol.state: "complete", and only when it also binds averification_receiptwhose request and decision are the ones the pointer records — the assembler accepts any--outname under its artifacts root, so an older canonical receipt cannot be mistaken for the one a run just closed. A scan or a preview cannot represent completion authority at all, and each pointer binds only the artifacts its own run wrote: ascan --format markdownafter a verify no longer claims that verifier'sreport.json. Supporting scans stay isolated —verify's internal head scan does not take over the PR's control identity, andbaseline savealready scanned into a temporary directory. Contract19 → 20addscurrent_control_schema_version,current_control_artifact, theagent_refresh_triggers[]list of boundaries at which a cached control state expires, andcurrent_control_fallback_read_order[]for consumers built before the pointer existed;agent_read_order[]now starts at the pointer, and the local downstream contract moves7 → 8. Generated agent instructions and both adoption kits now require the refresh. No report, packet, verifier, handoff, or receipt schema changed. (#339) -
An unreadable PR diff is no longer reported as "nothing here is agent-related."
verify --previewcollapsed every diff-acquisition failure into one message, then evaluated the trigger catalog against the empty inputs that failure left behind — publishingskip_reason: "no_match"with the rationale "nothing in this PR signals a tool-surface change" about a PR it had never read. The top-level control result stayed fail-closed (merge_verdict: "unknown"), but the explanation invited exactly the wrong conclusion, and on an unconfigured workspace the failure was not reported at all: both diff-failure branches were gated on a manifest being present, so a shallow or blobless clone of an un-adopted repository — the normal shape of first contact — fell through to "Shipgate is not configured in this workspace" with the Git error visible nowhere butbase_notes. Three things changed. Diff acquisition is now classified rather than flattened:not_attempted,refs_missing,merge_base_missing,unrelated_histories,objects_missing,metadata_limit_exceeded,body_limit_exceeded,git_timeout, andgit_failedare read off Git's own diagnostic — including the two causes Git reports identically as "no merge base", a shallow checkout that deepening repairs versus two roots that no fetch can ever join — and travel on the newverifier.jsondiff_statusblock together with a bounded, path-redacted excerpt and the precise repair — deepen history, hydrate partial-clone objects (verification setsGIT_NO_LAZY_FETCH=1, so Git will not fetch them implicitly), or take it to a human when fetching cannot help. Metadata and body are collected separately, so a diff whose body cannot be read no longer discards the changed paths that were read successfully; a blobless clone answers--name-statusin full, and those paths are exactly what says a PR touches an agent surface. And the trigger evaluator gained the state it was missing:input_statusandevaluation_status, withshould_run,run_shipgate,skip, andskip_reasonallnullwhen the inputs were not fully read. The asymmetry is deliberate — rule matching is monotone in the evidence, so a run verdict reached from partial evidence stays sound and is still published, while any skip verdict is withheld. Trigger catalog schema0.2 → 0.3(nullable verdict fields, the two new fields, and the newnext_action.kind: "input_required"); verifier schema0.6 → 0.7(diff_status; v0.6 remains a frozen reference and is still readable).contract_version,report_schema_version, and every other schema counter are unchanged. -
Google ADK repositories that share one tool between agents can be scanned again. Binding the same
FunctionToolto a coordinator and its sub-agents is the canonical ADK multi-agent shape — it is whatgoogle/adk-samplesdemonstrates — and it aborted the scan withDuplicate tool observation identitybefore any finding orrelease_decisionexisted. That is worse than an abstention:insufficient_evidenceat least routes a human, while a hard input failure produces nothing to act on, so on real multi-agent ADK repositories the supported adapter returned no gate at all. The extractor emitted oneToolper agent binding, and catalog observation identity is(source_type, source_id, native_locator)where the locator is the file plus function name — so the second binding of one function collided with the first. The fix is not to widen that identity with the agent name: one function is one action, and minting a capability per binding would inflate every count derived from the catalog and quietly change what "unique tools" means. The function is now observed once, and the many-to-many binding relation travels as framework-ownedAgentBindingObservationrecords — the same surface the OpenAI Agents SDK adapter already uses — so all three bindings survive as first-class edges in the binding graph, in root-reachability, and in each tool'sbinding_assessment.claims[], each claim pointing at its ownLlmAgent(...)call site. Sharing a tool is still distinguished from declaring one twice: the observation-identity guard is untouched, and a source that genuinely repeats a declaration still fails closed. A toolset assigned to a variable and shared between agents is likewise loaded once rather than once per binding, and a function bound as bothFunctionToolandLongRunningFunctionToolkeeps the stricter long-running contract and raises a warning instead of letting binding order decide. Two consequences worth knowing when upgrading: ADK Python tools no longer carry the single-valuedadk_agent_nameannotation (bindings can no longer be read off a tool, which was only ever able to name one of N agents), so the first scan after upgrade may report a metadata-only annotation-hash change for ADK tools — tool identities, fingerprints, baselines, and decisions are unaffected; andframeworks.google_adkgainstool_binding_countalongsidefunction_tool_count, which now counts tool definitions, so a function shared by three agents reads as one tool and three bindings.tool_binding_countis additive and not yet listed in the published report schema'srequiredset;report_schema_versionstays at0.34. -
Standalone scans now retire the complete verifier route as one lifecycle set. A later
scanremovesverifier.json,agent-handoff.json, the PR comment and run projection, and every verification identity input and output before publishing a replacement report, so stale control actions cannot survive beside a newer release decision. Cleanup failures now return the exact artifact path and recovery action in agent mode.baseline savekeeps its supporting scan in a temporary directory, preserving both the current report and forensic verifier evidence. -
The trigger catalog now recognizes a Google ADK
tools=[...]list, and stops calling a bare package token a version bump. A PR adding an ADK sample whose root agent isLlmAgent(name="support_agent", tools=[ lookup_account, delete_account])— two directly reachable tools, one of them destructive — routed asskip_reason: "dry_run_only". The only rule that fired wasTRIGGER-FRAMEWORK-VERSION-BUMP, on a rawgoogle-adkstring somewhere in the diff, and it reported the result as a framework upgrade. Two separate defects sat behind that. The first was catalog drift, not a missing capability: detection (GOOGLE_ADK_AGENT_CLASSES = {"Agent", "LlmAgent"}), the ADK adapter, and the binding graph all resolve this exact shape, and the same sample reports every catalog tool reachable —docs/triggers.jsonwas the one component carrying no ADK rule at all. Plain functions handed totools=[...]carry no decorator, so@function_tool/FunctionTool(never sees them, and the framework's most common agent spelling had no positive route.TRIGGER-GOOGLE-ADK-AGENT-TOOLS-CHANGEDcloses that: atools=[...]argument alongside eitherLlmAgent(— a class name no other supported framework exports, so it identifies ADK by itself — or agoogle.adkmodule path plus anAgent(construction. Both legs are needed, and for a reason that is easy to get wrong: requiring the import covers only whole-file additions, because the ordinary edit that adds one tool to an existing agent shows the constructor and the list but leaves the import far outside the hunk.Agent(stays gated behind the ADK token because CrewAI buildsAgent(..., tools=[...])too, and routing that under a rule ID naming Google ADK would repeat the defect below. The residual gap is a modified list on theAgentalias, which diff text alone cannot attribute; it is documented in the rule and in AGENTS.md. The second defect is the more general one — the rule stated a conclusion its evidence could not support. Nothing about the stringgoogle-adkestablishes that a dependency version moved; it comes just as easily from install prose or a sample import, which is why a docs-only change could be classified as a framework upgrade.TRIGGER-FRAMEWORK-VERSION-BUMPnow requires both halves of its claim — the package token and a changed dependency manifest — and its rationale says what it observed (a co-occurrence) rather than what it inferred (an upgrade). What is gone is the route where a README mentioning a framework was reported as one. The manifest set is not a hand-written list in the catalog: a first cut of this change was one, and it silently dropped advisory coverage for every pip-tools repository, which authors a bump inrequirements.inand compiles it torequirements.txt— a realgoogle-adkbump in a.infile went fromdry_run_recommended: truetono_match. The set now lives inDEPENDENCY_MANIFEST_GLOBS(agents_shipgate.core.dependency_manifests), covers both halves of the pip-tools pair plus the modern lockfiles (pdm.lock, PEP 751pylock*.toml, bun, conda) across Python, Node, and the JVM, and is projected intodocs/triggers.jsonunder a contract test that fails when the two drift — the same guardboundary_adaptersalready had. On net, real-bump coverage is wider than before this change, not merely preserved. The rule ID and the catalogschema_version(0.3) are unchanged: rule IDs are stable for0.x, and this is rule precision inside the existing schema, so an external agent that pre-fetched the catalog keeps working. Both rules are diff-only, so the path-based pre-commitfiles:pre-gate still cannot decide them; that caveat is now stated for the dependency rule too, which had grown a path leg. -
input_set_idnow covers every input the adapters actually read. (#299)input_set_idis the identityverification-plan.json,verification-unit-result.json,verify-run.json, the terminal receipt, and attestations all rest on, and its whole claim is that two runs sharing it read the same bytes. Three things broke that claim. The manifest-derived branch ofbuild_verification_planwalked onlytool_sources, soopenai_api.prompt_files— and every other framework block that names paths:anthropic,google_adk,langchain,crewai,n8n,codex_plugins,validation.evidence,checks.policy_packs,agent.sdk.entrypoint— never became a plan blob. Rewriting a prompt to say refunds need no approval leftinput_set_idbyte-identical. Worse, the observed branch was inert on the committed-tree path:verify --base X --head Yevaluates an archived copy of the head tree, while the static-input snapshot that records adapter reads is bound to the worktree, so it captured nothing and the run emittedtool_sources: []. On the CI path — where the receipt is the artifact anyone downstream actually trusts — no input reached the request identity at all. And enumerating the manifest, however completely, can never reach an input the manifest does not name: a Google ADKMcpToolsetinventory or an OpenAPI spec constructed insideagent.pyis discovered while parsing, not declared. Two trees whose MCP inventories differed by a trailing newline produced the same preparedinput_set_id. Identity is therefore taken at the read boundary, not from declarations. Each producer snapshots the tree it evaluates and records what the adapters open: a committed-tree run is now snapshotted against the archived tree it scans (previously impossible — the snapshot watched the worktree), andverification prepareloads sources to record their reads. Committed-tree and worktree runs of the same tree now bind the same set, asserted as an invariant in the suite. Enumerating declared paths survives only as the fallback for a plan built with no snapshot at all; that table is derived from the manifest models rather than hand-kept, so a new artifact list — or a whole new framework block — is covered without editing it. Capture also has to supply the bytes, not just the path list. Recording what was read and then reopening those files to hash them takes the two halves of the plan from two different instants: a file rewritten in between is attested at its new content whiletool_sourcesstill lists what the old content pointed at, so the receipt describes bytes the scan never evaluated. Plan construction now runs under the finalized snapshot on both paths. That makes binding an obligation rather than an optimization: under an active snapshot both_blobsand_optional_blobread a path that is contained but never read as absent, so an input nobody bound does not merely go unhashed — it disappears from the plan. Every input the plan hashes is therefore bound before the snapshot is sealed: the adapters' own reads, the changed files (a README no adapter opens would otherwise vanish fromchanged_files), and the explicit--baseline,--diff-from, and policy packs, with the comparison report bound as an external input on a committed-tree preparation because it is never mapped into the evaluated tree. The manifest itself is now read once, through the snapshot, and parsed from those bytes.load_manifest_with_positionsotherwise reads it twice — a directPath.read_textfor the model, then the snapshot for positions — so a rewrite between the two let the adapters follow one manifest while the plan's config blob attested to another: a receipt could name an entrypoint the scan never opened. The worktree path always passed its captured text for this reason; the committed-tree path andverification preparepassed none. A committed-tree run therefore has two snapshots alive, and each external input must belong to exactly one of them: the worktree snapshot binds the baseline, policy packs, and comparison report before the archived scan starts, so its tamper check still covers them — and covers a wider window than before, since it now begins before the scan rather than at the scan's first read. Letting both snapshots watch the same external directory instead makes the second re-validation fail on a change the first legitimately allowed. Two behavior changes worth knowing.verification preparereads inputs now, so it fails on a manifest whose inputs cannot be loaded; that is the same condition under whichverifyfails, and exactly when a prepared plan could not honestly claim an input set. It also routes its errors instead of printing a traceback, through the shared diagnostic catalog rather than a local guess: an absent manifest gets the setup route (config_error, exit 2, the same answerscangives), an unparseable one gets the edit route, and an input that moved mid-run getsinput_parse_error, exit 3 — the distinction matters because agents branch on it. And the declared-path fallback rejects a path resolving outside the verification input root, since it cannot be hashed portably. No schema changes:plan.inputs.tool_sourcesgains entries, not fields. Existinginput_set_idandrequest_idvalues do move — which is the point, and means a receipt minted before this change cannot be compared by ID against one minted after. -
SHIP-VERIFY-POLICY-WEAKENEDcan now actually see a weakened CI gate. Theeffective_policysnapshot was built from the manifest after CLI overrides were folded into it, so it described the invocation rather than the repository — andverifyoverrides both sides, differently. The base tree is scanned with a forcedci_mode="advisory"(load-bearing: it keeps a base scan from failing the run on the base's own findings), so every cached base report recordedeffective_policy.ci_mode == "advisory"no matter what the base declared. Comparing that against the head manifest madehead_rank == base_rankfor the one case the check exists to catch: a PR downgradingci.modefromstricttoadvisoryemitted nothing. The flagship claim is that an agent cannot quietly weaken its own gate, and on this axis the specific finding that names the weakening never fired. (Such a PR still routed to a human throughSHIP-VERIFY-TRUST-ROOT-TOUCHED, so this was a missing name on a real review, not a silent merge.) The same root cause ran the other way onfail_on, where only the head carries the override:verify --fail-on highagainst a manifest declaringfail_on: [high, critical]compared a head snapshot of["high"]against a base snapshot of["high", "critical"]and reported a high-severity "this PR removes severities from the CI fail-on set" against a PR that touched no policy at all. The snapshot is now built from theciblock as declared on disk, which fixes both directions at once — the defect was never "the base is forced to advisory", it was "the snapshot describes the invocation". The run's own gate is unchanged: top-levelreport.ci_mode/report.fail_on, the exit code, andrun_idall still reflect the overrides. Fixing the producer alone would have left the bug observable, because a cached base report is admitted on a content hash — which proves it was not tampered with and says nothing about whether its fields still mean what the current CLI expects.__version__is in the cache key but does not move for a source checkout, an editable install, or between two builds sharing a pre-release version string, so a base report written before this change was reused verbatim and kept reportingadvisoryfor a base that declaredstrict. The cache-key epoch is therefore bumped (BASE_CACHE_KEY_EPOCH, 2 -> 3), which strands those entries on a key nothing computes; upgrading costs one re-scan per base tree and needs no manual cache clearing. Unit coverage had injected the baseEffectivePolicydirectly and could not see how the snapshot was produced, so the regressions drive real base and head scans throughverify --basein both directions, backed by a structural test that replays every CLI override_prepare_scansupports and asserts the snapshot is byte-identical to the on-disk manifest's. -
initno longer fails on a repository that names two files the same. A generatedtool_sources[].idwas the source type plus the file's basename, sostrix/tools/finish/tool.py,strix/tools/respond/tool.py, andstrix/tools/load_skill/tool.pyall rendered asopenai_sdk_tool— and the manifest schema, correctly, rejects a manifest whose ids repeat. Onetool.pyper tool package is the conventional Python layout, not an edge case, so on those repositories the primary adoption path failed outright:init --writeexited 4 withinternal_errorand wrote nothing, and the fallback it routed to (--minimal) discards the detection work for aCHANGE_MEtemplate. Worse,--minimalhad the same rule with no validation gate in front of it — two services that each shipopenapi.yamlgot an invalid manifest written to disk, and the documented next step (scan) failed on it with a config error telling the user not to re-runinit. Both renderers now derive the id from the whole workspace-relative path (openai_sdk_strix_tools_finish_tool), with no positional component — a_2suffix would have renumbered existing entries whenever an unrelated file appeared earlier in the walk. Sanitizing is lossy, so paths that still fold to one id (a-b/anda_b/) each take a digest of their own path rather than one side keeping the plain form; a collision is the one case where adding a file changes an id that already existed, nothing outside it is re-keyed, andinitrefuses to overwrite an existing manifest, so ids are assigned once per adoption. A digest prefix is not treated as a unique key either — two paths in one sanitized class sharing an 8-hex prefix are searchable in seconds — so whatever is still tied moves to a wider digest and the rendered set is unique by construction. A deep monorepo path keeps its most specific segments plus a digest, and the 64-character bound is enforced on the value that ships, disambiguated ones included. Verified end to end on the repository from the report: 23 sources, 23 unique ids,init --write→scanexits 0. Nested sources declared by--minimalchange id (they had no valid id before); ids in an existingshipgate.yamlare untouched —initrefuses to overwrite one. -
A protected-surface stop now names the route, and the non-route that looks like one. A human-routed preflight signal said only that a coding agent must not self-approve the edit. That leaves the agent to guess how a human decides, and the plausible guess is to ask the operator in conversation — which preflight does not read. The agent then either stalls on an answer nothing consumes, or treats a spoken "yes" as authority and proceeds past the gate; the first wastes a human context switch on a reviewer-requested edit, and the second is the gate teaching the behaviour it exists to prevent. The recommendation now states both halves: approval goes through the pull request, and the agent must not ask the operator to approve the edit in chat. This is text on an existing signal — no check id, schema, or routing changed. A trust-root edit is still
criticaland still stops the turn. -
A first adoption no longer reads as a policy weakening. Adding the manifest to a repository that had none is the first verdict every new adopter sees, and it said "This PR weakens the release policy that evaluates it", carried a finding titled "Policy change cannot be proven safe (no base snapshot)", and — because a missing-manifest base was classified as a safe recovery — shipped no
fix_taskat all, so nothing named the act that would clear it.verifynow proves adoption from git (the comparison base carries no manifest under any name and no YAML that parses as one — a text probe missed a valid manifest with quoted keys — so neither a moved manifest nor a base that quietly keeps one can pass itself off as a first adoption) and says so: same check id, samemediumseverity, samehuman_review_requiredstate, new evidence kindmanifest_introduced, and afix_taskwhose leading instruction names the exact configured manifest. Only when adoption is the sole gating concern does that instruction say to merge the adoption through a human-reviewed PR; blockers, insufficient evidence, and additional review items lead with their own stop condition.checkgets the same correction locally, keyed on the diff carrying exactly one manifest record and that record being a plain addition. Adoption remains a human decision — only the claim about what happened changed. -
The manifest a run actually loaded is a trust root. The trust-root table only knew
**/shipgate.yaml, so a repository run with--config new-gate.ymlhad no manifest trust root at all: the file defining its gate could be introduced or rewritten without a single finding, leaving the release substrate empty. With a clean scan that producedpassed/mergeable/complete— beneath an adoption headline that said a human was required. Whatever a run loads as its gate is now classified as one, in both the trust-root check and the policy fail-safe — and incheckandpreflight, which classified it no better: a diff for a custom-named manifest returnedallowwith no violations locally and no protected touch in preflight — the local check dropped it from the diff entirely before any evaluator saw it — and both then recommended a verify command for the defaultshipgate.yaml. Identity is compared on normalized, containment-checked paths, so an equivalent spelling (docs/x/../manifest.yaml) cannot slip past, and preflight classifies the source side of a rename, which is where the gate sits when a diff moves it out from under itself. The classification is recorded on the boundary row so a gate-governing surface stays out of the graded agent route regardless of its name, and the evidence carries the changed path rather than the resolved config path, which for a committed-head run is a temporary archive location that would make two identical runs produce different fingerprints. -
checkdetects which agent is running it.--agentdefaulted tocodexand never consulted the harness variables Shipgate already reads to switch on agent mode, so every Claude Code and Cursor run recorded the wrong actor in its result and audit id. Detection now comes from one table that also defines those hints, so the two cannot drift; an explicit--agentstill wins, and a plain shell still getscodex. -
check,audit, andpreflighthonor the agent-mode error contract. The skills and slash command tell agents that withAGENTS_SHIPGATE_AGENT_MODE=1a failing command emits a structurednext_actionline on stderr; these three printed prose only, so an agent that mis-invoked them had to parse English. Each error path now emits the line with itsexit_code, andpreflightno longer lets an unexpected failure escape as a bare traceback. -
A rerun command that actually reruns. The
fix_taskverification command omitted--config, substitutedorigin/mainfor a base the run never used, and always appended--head HEAD. In a repository with a nested manifest it re-ran against a different gate, and for an uncommitted first adoption it switched to the committed tree — where the new manifest does not exist — and exited 2. It now emits the config always, the base only when one was used, and no--headfor a working-tree run — plus the rest of the evaluated request (policy packs, baseline,--ci-mode, plugin and heuristic modes, and an explicit--no-base), because a rerun that drops them evaluates a different question than the one whose findings it is meant to reproduce. The structured adoption repair now names the resolved config path instead of a hardcodedshipgate.yaml, and the command carries the resolved--workspaceand a non-default--out, so a rerun from another directory evaluates the same checkout and writes to the same place.check's recovery command is rebuilt from the failing request with only the invalid field corrected — a fixed command discarded actor, workspace, config, policy, and diff context — and a request whose diff came from stdin gets a review action instead of a command that cannot be replayed. -
Preflight recovery keeps the request it failed on. Every preflight error recommended a bare
agents-shipgate preflight --json, discarding workspace, config, plan, diff, and capability request: following it after a failed targeted run evaluated the current repository with an empty plan and returnedcontrol.state=complete. The recovery action now reproduces the actual invocation, and offers no command at all when the request came from stdin or mixed--planwith the per-flag inputs — replaying a request-shape conflict can never satisfy its ownexpects.check's recovery is one quoted serializer for every path, including diff-input failures, which previously joined user-controlled paths and refs unquoted into a published authorized command; commands the CLI emits for its own targets now name the workspace and the manifest, with the config rendered the wayverifyresolves it — relative to the repository root — so a nested manifest is no longer verified against the root gate. -
Detached diffs never authorize checkout-dependent verification. A diff supplied by file, stdin, or the read-only MCP adapter can be evaluated for diagnostics, but it is not proof of the bytes a later
verifycommand would read. When such a result owes verification, control now stops with no allowed command and the summary says to rerun against the intended worktree or a complete ref range. MCP preflight also rejects aplanmixed with direct request fields instead of silently discarding one input source. -
A failed baseline is never recovered by overwriting it. A malformed, unknown-schema, or integrity-failed host-grants baseline recommended
--save-baselineagainst the same path, which replaced the failed artifact with the current grants — acknowledging them unreviewed and destroying the evidence a human needed. Those now route to review. A genuinely absent baseline also routes to a human because creating the first baseline acknowledges the current grants; a failed read-only drift request never authorizes that state-changing decision. -
Host-audit filesystem failures follow the catalog. A
--baseline-filenaming a directory raisedIsADirectoryErrorthrough typer as a traceback and exit 1. Filesystem failures on both--baseline-fileand--outare nowother_errorwith exit 4, asdocs/errors.jsonspecifies — they were briefly reported asconfig_error/2, which sends an agent back to re-read flags that were fine. The baseline reader needed the same treatment through its__cause__, since the loader converts every readOSErrorintoValueError; a genuinely missing baseline remainsconfig_error/2 but now carries a review-only route for the first acknowledgement. -
The audit id distinguishes the actor. Detecting the calling agent changed the label in the result but not the digest — the central one omitted the actor and the legacy one hardcoded
codex— so identical evaluations by Claude Code, Cursor, and Codex shared anaudit_id, which is exactly the attribution problem actor detection exists to solve. Legacy replayable provided-diff Codex ids keep their established shape. Non-default actors add actor identity; worktree, ref-range, and detached evaluations also bind input/control replayability, so those ids intentionally rotate. Semantic control state is hashed without checkout-specific command paths. -
Static control inputs now fail closed on identity and resource ambiguity. Local check, preflight, host audit, installed hooks, and verifier Git collection bind exact non-symlink, singly-linked regular files; manifest, policy, baseline, trust-root, diff, and Git inventories have byte or entry ceilings. Executable filters, repository diff drivers, hidden index flags, source-like binary diffs, malformed/coherence-breaking diff records, and filesystem-portability collisions stop instead of silently dropping source text. Prior verifier output is excluded from a worktree request so an identical rerun does not hash its own artifacts.
-
Portable host instructions are protected consistently. Boundary matching is case-insensitive and hierarchical for
AGENTS.md,AGENTS.override.md, andCLAUDE.md; a case variant or nested copy cannot acquire authority only after checkout on another host. Symlink directories that could conceal a leading-**/trust root make host inventory and preflight incomplete and route to human review. -
Mechanical repair authorization is subject-bound. A coding-agent repair route now requires an applicable high-confidence non-manual patch against a worktree subject. A ref-bound verifier cannot authorize a patch command that would edit the checkout and then rerun the unchanged commit; it routes that repair to a human instead.
-
Adoption wording stands down when something was genuinely weakened. Introducing the manifest while editing an existing policy file produced a
base_snapshot_unavailablefinding under a headline saying there was no prior gate to weaken, and dropped thereview_policy_weakeningrepair. The pure-adoption wording now requires that nothing else needing review changed. The adoption proof itself also stopped resting on two basenames: a base that simply keeps an operational manifest under another name deletes nothing and matches no name check, so absence is now established by content. -
A way out of
insufficient_evidence(#292). An abstention was unactionable in practice: the decision engine generated the exact manifest snippet each evidence gap wants, but those snippets were only reachable by walkingreport.json, so a three-line, one-time task looked like schema archaeology and repositories stayed abstained indefinitely.suggested-declarations.yamlnow assembles them next to the report — merged per target, so two gaps on one tool produce one pasteable row instead of two invalid ones — and every gap that carries a template names the file in itsexpects. Every human-owned value stays<REVIEW_REQUIRED>, and the file states that a block still containing a sentinel closes nothing. Verified end to end: filling the scaffold clearsinferred_effect_onlyandmissing_authority_evidenceand moves the verdict off abstention. -
Unfilled scaffold placeholders are rejected by the manifest. The scaffold states that a block still containing
<REVIEW_REQUIRED>closes nothing, but the manifest only checked fields likeauthority.auth_typefor non-blankness — so a pasted-but-unfinished block loaded and was assessed as reviewed evidence, moving a fixture frominsufficient_evidencetoreview_requiredon placeholders alone. The loader now rejects the sentinel wherever it appears and names each unfilled path, so an unfinished scaffold cannot change a verdict. -
The authority template was unfillable. It offered
authority.modealone, but the manifest requiresauth_typefor every mode exceptnoneand non-emptyscopesforscoped— a reviewer following it exactly got a config error. Surfacing the templates is what exposed it. The template now names the co-required fields, and a regression test validates the shipped shape against the manifest schema. -
Evidence gaps say whether this diff caused them.
verifyalready scans the base; it now compares the base and head gap sets and reports whether the diff introduced a gap or inherited it, so a docs-only turn stops reading as an accusation about the current change. The verdict is deliberately unchanged: evidence coverage is a property of the whole evaluated surface, and a diff that appears to touch nothing is exactly what an unseeable capability change looks like, so the diff can never argue an abstention away.docs/engineering/insufficient-evidence-cold-start.mdrecords why the diff-scoped variant was rejected. -
Framework-correct low-confidence remedy. The advice named
tool_inventoriesfor every framework, but only four have that key;openai_agents_sdk— the quickstart framework — has none, so readers were sent after a key the schema rejects. The remedy now names the real key when one exists and the supported alternative when it does not. -
Graded local boundary stop (UX P0, contract v19,
0.16.0b7). The localshipgate checkpreviously projected everyrequire_reviewboundary violation onto the samehuman_review_required+must_stop: truecontrol state as ablock— a CLAUDE.md comment, an unknown.claude/settings.jsonkey, and a critical grant expansion were operationally identical, which routinely hard-stopped coding agents on user-requested benign edits. Contract v19 routes arequire_reviewset that is entirely low/medium risk tocontrol.state: "agent_action_required"with the exact verify command; the review obligation is preserved in the new additivepending_review[]field onshipgate.agent_boundary_result/v1and re-asserted by PR-time verify, whoserelease_decisionbranching is byte-identical. The band is fail-closed:blockactions,criticalrisk, incomplete or unparseable input, gate-weakening rules, experimental surfaces, and every gate-governing trust-root class (manifest,policy,ci_gate,shipgate_state) keep the human stop. Root/case-variantAGENTS.md/AGENTS.override.md/CLAUDE.mdinstruction identities do too, preserving the composite-diff guarantee from the agent-authored proposal work. The deprecatedcodex-boundary-jsonformat grades identically; its frozen v2 schema does not carry the new field. -
Stop hook follows
control.state(0.16.0b7). The installed Claude Code Stop hook blocked the agent's stop on any non-passedrelease decision — but a Stop-hook block forces the agent to KEEP working, which is exactly wrong forhuman_review_required(must_stop: truemeans "end the turn and hand off to a human"). The hook now mirrors the operational contract:completeends the turn silently,agent_action_requiredblocks once and names the one exact remaining command,human_review_requiredprints a hand-off notice and lets the turn end. Unparseable or unrecognized verifier output warns loudly, is never cached by the verified-signature short-circuit, and is never treated as passing; the cold-start no-manifest case advisesverify --previewinstead of forcing continuation. Reinstall hooks to pick up the new behavior. -
Own-repo CI verify gates on
blocked,unknownagain (0.16.0b7). The 0.16.0b3-era expansion offail_on_merge_verdictsto includehuman_review_requiredandinsufficient_evidence(#274) made every trust-root-touching PR — including routine release pin sweeps that bump the plugin manifests — permanently red: no verifier mechanism can clear ahuman_review_requiredmerge verdict, and the verdict's own semantics are "release is allowed but the human reviewer should weigh in." Both advisory workflows now fail only onblockedandunknown(fail-closed against parse/contract breakage); review routing remains visible in the uploaded verifier artifact and the PR reviewer stays the deciding human. The GitHub Action's own defaults are unchanged. -
Version advances (
0.16.0b7). Runtime contract18 → 19. All other schema versions are unchanged;pending_review[]is additive on the regeneratedagent-boundary-result-schema.v1.json, andminimum_control_contract_versionstays14— theAgentControlunion, its fixedmust_stop/completion_allowedliterals, and the release gating signal are untouched. -
Reproducible verification identity (P0,
0.16.0b6). Verify now binds the resolved Git subject, exact input blobs, evaluation date, behavior options, installed engine-content and dependency/adapter/plugin/policy set, normalized task, executor, assembled decision, and complete artifact set through content-addressed IDs. Exact Git objects are materialized withls-treeandcat-file; base-cache reuse is invisible to public artifacts and guarded by a content hash. -
Terminal receipts and portable execution boundary. Successful verify runs emit
verification-plan.json, a decision-freeverification-unit-result.json,verification-artifacts.json, and, last,verification-receipt.json. Workers validate their installed engine and transported inputs but cannot assert a verdict; the verifier remains the sole policy engine and the assembler re-closes its decision.verification prepare|worker|assemble|reproduceexposes the portable v1 protocol without claiming distributed policy evaluation, arbitrary sharding, or parallel speedup. -
Externally rooted exact-operation authorization (contract v18). A trusted host can derive an unsigned authorization request from a host-attested
review_requiredreceipt, authenticate a human, and return a short-lived Ed25519 grant for one exact force-with-lease Git push. Agents Shipgate ships no signing or approval command. A second verification recomputes the complete request, decision, review set, repository, and tree identities before an accepted grant exposes only the guardedauthorization executeconsumer; the release decision, merge verdict, and completion authority remain unchanged. The executor revalidates current evidence and expiry immediately before using an isolated Git object store. Authorization requires an exact plugins-disabled engine, rejects third-party plugin loading in the broker, and parent-streams the Git pack with bounded stdout, stderr, and time. Authorization remains disabled without a host-protected trust policy, launcher, interpreter, entire virtual environment andsite-packagestree, dependencies, credentials, and separately installed distribution; same-UID modes and editable workspace installs are not a trust boundary. -
Identity and authorization contract versions. Runtime contract advances to v18; report to v0.34; packet to v0.12; verifier to v0.6; verify-run to v3; handoff to v6; attestation to v0.5; registry to v0.4; organization evidence bundle to v2; downstream local contract to v7; and safety qualification formats to v4. Verification plan, unit-result, artifact-manifest, and receipt schemas begin at v1. The authorization request, signed grant, verifier evaluation, and external trust-policy schemas also begin at v1. Prior schemas remain frozen readers.
-
Immutable CI subject. The GitHub Action evaluates
github.shaby default and treats the default pull-request synthetic merge as authorization- ineligible. Push authorization requires a separate verification of the exact PR head commit. The Action exports receipt, request, decision, and artifact-set identities only after validating every terminal artifact hash. -
Non-forgeable trust decay. The content-bound commit evaluation date remains reproducibility provenance, but cannot extend reviewer-owned trust. Baseline, acknowledgement, and severity-override expiry use the later of that date and the verifier wall clock, so a forged backdated commit fails closed.
-
Agent-authored coverage proposals (contract v18 clarification). Preflight and local control now distinguish proposal authorship from approval for one narrow manifest shape: an exact append-only addition of valid built-in
tool_sourcesrows may be authored by a coding agent and routed to verify. Existing rows, all other manifest values, authority-bearing fields, custom adapters, and unsafe paths remain human-routed; the concrete trust-root diff still requires reviewer approval. Conventional test/golden fixtures are no longer inferred as undeclared deployed surfaces unless the manifest explicitly declares them. No schema or runtime-contract version changes. -
Codex marketplace coverage and plugin-path containment. Local plugin packages reached through a declared Codex marketplace now count as declared tool surfaces for local-control routing, and detect/init plus the zero-install detector no longer propose redundant direct-package rows for those roots. Direct-package loading now hard-rejects a source whose
.codex-plugin/plugin.jsonsymlink target escapes the manifest directory; marketplace entries with the same escape are skipped and cannot grant coverage or supply verification bytes. Malformed, non-UTF-8, oversized, remote, or escaping marketplace inputs stay fail-closed. No schema or runtime-contract version changes. -
Evidence-basis policy gate (P0,
0.16.0b5). Semantic claims and risk hints now carry a typed evidence basis, stable claim IDs, and derived policy eligibility. Policy-pack and action-policy predicates evaluate tomatched | not_matched | indeterminate | conflicting; rule severity, confidence,block: true, manual tags, and risk overrides cannot upgrade heuristic or incomplete evidence into an authoritative finding. -
Non-waivable policy applicability gaps. Heuristic-only, mixed, unknown, or conflicting applicability is emitted outside Findings and routes to
insufficient_evidence. Baselines, suppressions, severity overrides, acknowledgements, and--no-heuristicscannot hide it. Supported findings expose deterministic predicate support and asupport_hash; baseline v0.8 requires that hash to remain equal. Pre-v0.8 baselines cannot supply that binding, so supported findings re-gate as new until a human reviews the new evidence and re-runsagents-shipgate baseline save. -
Evidence contract versions. Runtime contract advances to v16; report to v0.33; packet to v0.11; verifier to v0.4; handoff to v4; policy pack to v0.4; capability standard to v0.5; lock/diff to v0.6/v0.7; action snapshot to v0.4; downstream local contract to v5; and safety qualification formats to v3. Existing finding fingerprints and all prior schema files remain frozen.
-
Complete zero-config multi-host boundary (
0.16.0b4). The local boundary check now evaluates every recognized changed Codex, Claude Code, Cursor, VS Code MCP, shared instruction, and GitHub workflow surface through one static assessment.--agentidentifies the caller and can no longer be used as a coverage selector. Untracked, deleted, malformed, unreadable, symlinked, and oversized protected inputs fail closed instead of disappearing from the result. -
Host-neutral boundary contract. Runtime contract advances to v15 and the canonical check format becomes
agent-boundary-jsonwith schemashipgate.agent_boundary_result/v1. The frozenshipgate.codex_boundary_result/v2projection remains available through the deprecatedcodex-boundary-jsonspelling for the0.16.xline. The sharedAgentControlcontract remains v14. -
Evidence-bearing host inventory. Host inventory, baseline, and drift advance to v0.2 with typed redacted grants, artifact parse status, per-host coverage, explicit excluded scopes, and incomparable migration for v0.1 baselines. Repository scope remains deterministic and default; the explicit
local-staticaudit scope reads supported local configuration without executing hosts, helpers, tools, user code, or network calls. -
Boundary beta hardening. Visible permission-mode and sandbox grant values now use the same recursive secret redaction as their hashes, so inventories, saved baselines, and drift reports cannot persist raw credential-bearing strings. Incomparable host baselines route preflight and organization status to human review instead of appearing clean. Protected-path classification is case-insensitive and retains nested Codex, MCP, and GitHub-workflow copies.
-
Correction to the original host-governance claim. Earlier documentation overstated the first host-audit cut as the effective/current grant set. The contract is static and scope-bound: repository results cover repository-declared surfaces, while local-static results still exclude session approvals, invocation flags, UI state, remote managed settings, and runtime enforcement.
-
Unambiguous agent control contract (P0,
0.16.0b3). Check, preflight, verify, handoff, MCP, verify-run, and GitHub Action projections now share one schema-enforcedAgentControlstate:complete,agent_action_required, orhuman_review_required. Pending verification, installation, safe repair, and input recovery no longer coexist with completion or a human stop; conversation-level acknowledgement cannot clear a control obligation. -
Control contract versions. Runtime contract advances to v14; boundary result to
shipgate.codex_boundary_result/v2; verifier to v0.3; handoff toshipgate.agent_handoff/v3; preflight to v0.3; verify-run toshipgate.verify_run/v2; and the downstream local contract to schema v3. Prior schema files remain frozen. Report v0.32, packet v0.10, capability standard v0.4, and capability lock/diff v0.5/v0.6 are unchanged by this control-contract milestone. -
Execution, applicability, and mergeability are separate. Verifier v0.3 publishes execution (
not_run | succeeded | skipped | failed) separately from applicability (not_evaluated | verified | not_applicable | failed).can_merge_without_humanis true only for a verifiedpassedresult or a completed deterministic non-applicable skip. GitHub Action outputs addagent_control_stateandagent_control_reason; legacy booleans remain exact derived mirrors for one compatibility cycle. -
Conductor OSS workflow JSON adapter. A built-in, per-scan
conductorsource statically enumerates literal MCP calls and records MCP discovery, LLM tool advertisements, HUMAN checkpoints, nested control-flow tasks, and local sub-workflows. Dynamic or unresolved tool surfaces emitSHIP-CONDUCTOR-DYNAMIC-TOOL-SURFACE-NOT-ENUMERABLE; unsupported worker, HTTP, A2A, provider-native, and runtime-generated capabilities remain explicit evidence gaps. Report schema advances from the frozen binding contract v0.31 to v0.32; manifest v0.1, packet v0.10, and runtime contract v13 remain unchanged.conductoris now a reserved built-intool_sources[].typeand may conflict with a third-party adapter that previously used the same source type. -
Root-reachable agent binding graph (P0,
0.16.0b2). Tool catalogs no longer become an agent's capability surface by extraction alone. Framework adapters emit static tool and handoff edges,agent_bindingssupports exact reviewed closed-world declarations, and partial, dynamic, ambiguous, or conflicting graphs preventpassed. Reports separatetool_catalog[]from root-reachabletool_inventory[]and publish binding facts, diffs, coverage, evidence gaps, and human-routed remediation. -
Binding contract versions. Runtime contract advances to v13; report to v0.31; packet to v0.10; capability standard to v0.4; capability lock/diff to v0.5/v0.6; action snapshot to v0.3; and safety qualification formats to v2.
-
Provider-scoped canonical tool identity (P0). Tool observations now get deterministic source-scoped IDs and same-name tools from different providers remain distinct. Cross-source evidence joins only through exact, reviewed
tool_identity.bindings[]; invalid bindings and ambiguous one-to-one selectors apply nowhere and route toinsufficient_evidence. -
Identity-safe policies, diffs, traces, and debt. Action declarations, controls, risk overrides, suppressions, packets, tool/action diffs, capability lineage, trace matching, and finding fingerprints consume the canonical tool identity. Fingerprint v2 hashes
tool_id; legacy baseline matches are accepted only for an unambiguous current identity. Pre-v0.30 reports and pre-v0.4 capability locks must be regenerated before diffing. -
Identity contract versions. Runtime contract advances to v12; report to v0.30; packet to v0.9; capability standard to v0.3; capability lock/diff to v0.4/v0.5; policy pack to v0.3; verifier to v0.2; action snapshot to v0.2; and agent handoff to
shipgate.agent_handoff/v2. -
Evidence-backed
passedverdict (0.16.0b1).passednow requires complete, conflict-free static surface, effect, and authority evidence for every in-scope action, evaluation of all applicable controls, and no policy condition requiring review. Unknown, inferred-only, protocol-defaulted, partial, invalid, or conflicting semantics route toinsufficient_evidence; known ambient/unscoped authority routes to review. Semantic gaps are not Findings and cannot be suppressed or baselined. This remains a static claim, not proof of runtime behavior or enforcement. -
Normalized semantic evidence contract. Report schema v0.29 and packet schema v0.8 add per-action/per-capability assessments, semantic coverage, and typed human-routed gap remediation. Manifest action declarations can provide reviewed
effectandauthorityevidence; Agents Shipgate never auto-writes those assertions. Contract v11 exposes this boundary asdo_not_auto_assert: [action_effect, action_authority, ...]. -
Machine-readable static-verdict boundary. Report release decisions, verifier artifacts, and agent-handoff gates now expose
static_analysis_only: true,runtime_behavior_verified: false, and the canonicalstatic_verdict_disclaimer; packet v0.8 §1 mirrors the report. -
Capability standard v0.2. Capability lock schema advances to v0.3 and diff schema to v0.4 so static capability facts carry the normalized semantic assessment. Runtime contract advances to v11; the source-tree package is
0.16.0b1while install examples remain pinned to the latest published tag,v0.15.0, until the beta is released. -
Qualification trust boundary is explicit. Beta promotion verifies an internally consistent production-qualification summary, its configured Sigstore identity, and exact wheel/tag binding. Organizational signer independence, blind labeling, receipt replay, and the four-week, three-design-partner rollout remain governed external controls rather than guarantees made by the promotion code. The machine policy enforces a combined minimum of 40 real-history, rejected/reverted, or design-partner origins.
-
First real-history accuracy numbers, published. The 2026-W26 mined corpus (120 merged PRs from stripe/agent-toolkit, block/goose, and pydantic-ai) is now labeled (two independent AI labelers, disagreement 0/10, third-pass adjudicated — pending human spot-check) and scored. On the 10 PRs the gate engaged, it never wrongly passed an authority-bearing change (
needs_human_caught1.0,benign_escalation_rate0.0) but also never cleanly passed a safe one (ie_rate_on_safe0.5, plus a since-fixed scan crash). Full confusion matrix and method inbenchmark/miner/README.md; the README status banner now carries the numbers instead of "none published yet". Real history contributes nomust_blockrows, so blocked-recall stays with the constructed-adversarial stratum. -
Config-bound dynamic-toolkit capability detection. New checks
SHIP-CAP-CONFIG-BINDING-REMOVED(high, suppression-immune) andSHIP-CAP-CONFIG-BINDING-CHANGED(review item) close the pilot blind spot where a diff removed or retargeted a factory's config binding — silently expanding the effective tool surface — without any capability delta showing in the diff. A conservative same-file config tracer (json/yaml/toml loads,os.environ, in-file pydantic settings) feeds them;config → unknownnever fires, guarding against false positives. -
Duplicate
action_surfaceaction_id collisions degrade instead of crashing. A base reference serialized by a pre-#226 engine could still crashscan/verifyat diff time withConfig error: Duplicate action_surface action_id; it now degrades to a source warning (review_required), matching the OpenAPI fix in #226. This eliminated the fourscan_failedrows in the W26 corpus. -
Claude Code plugin marketplace. The repo now doubles as a Claude Code plugin marketplace (
/plugin marketplace add ThreeMoonsLab/agents-shipgate, then/plugin install agents-shipgate@agents-shipgate) — the symmetric counterpart of the existing Codex marketplace. The plugin is skill-only (the auto-triggering skill + the namespaced/agents-shipgate:shipgatecommand); the scanner stays in the separately installed CLI and hooks stay on the explicitinstall-hookspath. Byte-identity with the canonical skill/command sources is test-pinned. Fixed in passing (caught byclaude plugin validate): the canonicalSKILL.mdand/shipgatecommand shipped YAML frontmatter with an unquoted:indescription, which Claude Code loads as silently-empty metadata — breaking description-based skill auto-triggering for every existing install. Both are now quoted, all byte-identical copies synced, and a regression test parses the frontmatter. -
Contract v10 (additive): machine-readable
verify_requiredon the Codex boundary result.shipgate checkalready escalated towarnand routed toverifywhen a diff touched a tool surface it cannot gate; that deferral now also sets a top-level booleanverify_requiredonshipgate.codex_boundary_result/v1, andverify_requiredjoinsagent_result_control_fieldsin the runtime contract. Agents switch on the field instead of parsing warning prose; the observable pair isdecision="warn"withverify_required=true— "no boundary rule fired, but capability is not yet gated: run verify before completion" (the escalation means a plainallowalways hasverify_required=false). The field lives on the sharedAgentResultV1base, so the legacyagent-result-schema.v1.jsoncarries it too andagent_result_control_fieldsvalidates against both schemas. Additive over v9: consumers pinned tocontract_version >= 9keep working.
- Versioning: the
1.0.0-alphaline is withdrawn; this work ships as0.14.0. An earlier draft of this cycle briefly carried1.0.0a1. That label was withdrawn: thereport.jsonschema (report_schema_version: "0.28") is still additive-versioned and not yet frozen, the package is stillDevelopment Status :: 4 - Beta, and no real-world detection-accuracy baseline has been published — none of which support a1.0line.0.14.0continues the0.xcontract line from0.13.0and carries the same agent-controller cleanup (see STABILITY.md). A1.0line will begin only when the report schema reaches1.0and holds without a breaking change. - Non-preview
verifynow fails closed on a missing--config.agents-shipgate verify --workspace . --config missing.yaml --jsonexits2withmerge_verdict: "unknown",applicability: "unknown", andcan_merge_without_human: false; it writes lightweight verifier/controller artifacts but noreport.jsonand runs no head scan. This replaces the old lenient path where a missing config could trigger-skip and exit0.verify --preview --config missing.yaml --jsonis unchanged and remains the setup/relevance path with exit0. - Shipgate now has a separate self-dogfood PR workflow. The root
shipgate.yamlremains the public Codex-plugin marketplace self-scan, whileshipgate-self.yamland.github/workflows/agents-shipgate-self.ymlrun an advisory static-only local-action gate on pull requests withfail_on_merge_verdicts: blocked, artifact upload enabled, and PR comments disabled. This does not scan Shipgate's Python scanner implementation; tests, coverage, audit, SBOM, and release signing remain that assurance path. - A named high concern now routes to review, not
insufficient_evidence. When a scan turns up an active (not baseline-accepted) high/critical review finding, the release decision is nowreview_requiredeven if low-confidence extraction would otherwise have producedinsufficient_evidence. Both verdicts are equally non-auto-mergeable, butreview_requiredpoints the human at a specific, actionable finding (e.g. the newSHIP-SCOPE-TOOLKIT-UNBOUNDED) instead of the vaguer "we couldn't see enough."blockedstill outranks everything; IE still fires when the only signal is thin extraction. The 2026-06-01 Stripe pilot's silent/IE case now surfaces as a routed review.evidence_gapsare preserved on the report either way, so the extraction-coverage signal is not lost.
-
Accepted-debt exception workflow (baseline schema 0.6).
baseline savegains--owner,--reason, and--expiresso the approval metadata the v0.5 provenance contract documented as "reviewer-set" is finally settable without hand-editing the file (which trips the integrity hash). Metadata is stamped on newly-accepted entries;--apply-to-existingfills the fields into existing entries that lack them — never overwriting a previously-set value and preserving each entry's originalrecorded_at/run_idhistory. Approval is declared, never inferred, matching thehuman_ackcontract. Newbaseline statusreports accepted-debt aging (owner, age, expiry, expiring-soon/expired/unowned summary;--as-ofpins the date for reproducible CI output) and turns into an org governance gate with--require-owner/--require-expiry/--max-age-days N— exit 20 on violations, advisory exit 0 without gate flags. Expired entries violate--require-expiry, and entries without provenance fail every active gate (unknown history is ungoverned debt, not exempt debt). Legacy 0.2–0.5 baselines still load; re-saving upgrades them to 0.6. -
Host-grant drift detection.
audit --host --save-baselinerecords the current coding-agent host grants (MCP servers, Claude Code permission rules and hooks, workflow scopes, Codex config presence) as the acknowledged state in.agents-shipgate/host-grants.json(content-only and byte-idempotent — no timestamps or machine paths; the directory is already a verify trust-root surface, so PR edits to the snapshot stay release-visible).audit --host --driftdeterministically diffs current grants against that baseline with per-category added/removed/changed buckets plusexpansion_signalsnaming the authority-broadening shapes (new or changed server, wildcard allow added,denyoraskrule removed, hook added or changed, workflow write scope orpull_request_targetgained). MCP server and hook entries carry aconfig_sha256over their full configuration; insideenv/headersonly values under secret-looking keys (shared sensitive-key vocabulary: token, secret, password, api_key, authorization, …) are redacted before hashing, so editing what an existing server or hook can do — args, commands, matchers, URL, key sets, or a grant-shaping value likeREAD_ONLY=false— is drift while credential rotation is not; the baseline's storedinventory_sha256is verified at load time and hand-edited or malformed baselines fail closed with exit 2. Advisory by default;--fail-on-driftexits 20 for scheduled CI gates — recipe atexamples/github-actions/12-host-grant-drift.yml. Catches authority changes that land outside PR review, where the diff-timeSHIP-HOST-BOUNDARY-*checks cannot see them. -
checkdefers tool-surface changes toverify(coverage boundary).shipgate checkis boundary-scoped and does not compute the capability delta, so a clean boundary result over a diff that changes a manifest-declaredtool_sources[].pathno longer returnsallow— it returnsdecision="warn"routingfirst_next_actiontoverify, with adiagnostics[].code="capability_change_requires_verify"marker and atrace[].step="coverage"event. Completion is still allowed, butcheckno longer green-lights a capability change onlyverifygates, so the local loop cannot disagree withrelease_decision.decision. Docs/test/boundary-only diffs are unaffected (stillallow); noagent_result_v1schema change. -
Agent-mode auto-detection. Agent mode now auto-enables when a known coding-agent harness environment is detected (Claude Code exports
CLAUDECODE=1, CursorCURSOR_TRACE_ID), so structurednext_actionerrors no longer require rememberingAGENTS_SHIPGATE_AGENT_MODE=1. An explicitAGENTS_SHIPGATE_AGENT_MODE=0still forces it off. -
Compact agent stdout for
verify.verify --format agent(new) prints the compactagent_result_v1payload (the same artifact written toagents-shipgate-reports/agent-result.json) on stdout, so oneverifycall closes the agent loop without a second file read. Bareverify --jsonresolves to this agent surface for verify runs (and to the full verifier JSON for--preview, whose relevance answer lives in thetriggerblock);verify --format jsonis unchanged. Inside a detected coding-agent environment, zero-flagverifydefaults to the agent format. -
Base auto-detection for
verify. When--baseis omitted, verify auto-detects the default branch (origin/HEAD,origin/main,origin/master,main,master) and uses it for diff context — but only when the detected ref points at a different commit than the head, so a clean checkout of the default branch keeps today's working-tree behavior. The detection never fetches.--no-basedisables it; an explicit--basealways wins. The auto-detected ref is recorded inbase_notes. -
init --claude-codeone-shot setup. A single flag wires the full Claude Code surface: theCLAUDE.mdmanaged block, the.claude/skills/agents-shipgate/skill bundle, the Claude Code hooks, and anagents-shipgate verify --jsonalias appended to Makefile /package.jsonscripts when those files exist. Idempotent, dry-run without--write, and reported under the additiveclaude_codekey ininit --jsonoutput. -
Pre-commit hooks now run the verifier. The
agents-shipgateandagents-shipgate-strictpre-commit hook entries switch from unconditionalscanto the trigger-gatedverifyflow (thefiles:regex pre-gate is unchanged), so local commits get the same merge-verdict surface as CI and diff-only trigger rules are evaluated once the hook fires. -
fix_task.patches[]. Whenverify --suggest-patchesroutes the repair to the coding agent, the fix task now carries the machine-applicable suggested patches ({finding_id, check_id, patch}with the discriminated set/append/remove-pointer payloads) so the agent gets concrete edits, not just prose instructions. Manual patches stay excluded and the field is additive — repair aid, never a gate input. -
fix_tasknames low-confidence sources oninsufficient_evidence. The verify fix task for aninsufficient_evidenceverdict no longer dead-ends at the threshold sentence: it names each low-confidence source (count, source type, ref) with the explicit-inventory remedy and quotes up to three source warnings. Complements the report-layerevidence_coverage.evidence_gaps[](schema v0.26); the route stays human because declaring an inventory asserts authority a coding agent must not invent. Deeper adapter-level config-bound toolkit detection is designed indocs/engineering/config-bound-capability-detection.md. -
Claude Code adoption surfaces reworked. The README gains a "Use with Claude Code" section,
docs/agents/use-with-claude-code.mdopens with the recommended one-commandinit --claude-codesetup, and theagents-shipgateskill description triggers on change artifacts (MCP servers/tools, tool decorators, permission scopes, approval policies, agent CI) instead of product-name phrases only. -
Cold-start dead ends now print an executable next action. Human-mode CLI error paths surface the same ranked recovery step that agent mode emits as JSON:
scan/doctor/verifyconfig errors print anext: …/why: …hint (e.g.next: agents-shipgate detect …on a missing manifest), and theinit --write→scanCHANGE_ME placeholder failure routes to the manifest edit instead of the generic missing-file advice — in both human and agent mode.verifyalso gains agent-mode structured errors (AGENTS_SHIPGATE_AGENT_MODE=1) and scan-parity flag-error vs run-error handling, so flag mistakes are never answered with manifest diagnostics. Hints are suppressed in agent mode to keep thedocs/errors.jsonsingle-JSON-line contract. Driven by the 2026-06-10 cold-start funnel test (marketing/cold-start-funnel-test-2026-06-10.md). -
Add the GTM plan of record (
marketing/gtm-strategy.md), launch kit, design-partner outreach kit, and launch blog draft; README shows the verifier PR-comment verdict ("What your PR sees") and links the coding-agent install path from the quickstart. -
Agent-native protocol.
shipgate check --agent {codex,claude-code,cursor} --workspace . --format agent-jsonis now the canonical one-command agent path. It returns the stableagent_result_v1contract with explicit completion, stop, repair, human-review, policy-provenance, source-artifact, and exit-code fields. -
agent_result_v1policy provenance is required in 0.13.0 producers. The schema name staysagent_result_v1; all in-tree producers now emit the requiredpolicyobject pluspolicy_snapshot_sha256. Consumers validating older v0.12.0 objects should treat this as the 0.13.0 schema publication point and update together with the package version. -
MCP server mode narrowed to
shipgate.check. The optional[mcp]server is now a read-only static adapter that accepts caller-provided diff text and returns exactagent_result_v1. The v0.12.0 preview tools (shipgate_preview,shipgate_verify,shipgate_explain_finding) were never listed inSTABILITY.md; they are removed in favor of the single agent protocol command/tool. -
Policy weakening detection now compares parsed before/after policy YAML from reconstructed file content when available, so quoted scalars, inline comments, and hunks that omit the rule id still block.
-
shipgate check --head <ref>or--base <ref>alone now fails closed with a structured CLI error. Provide both refs, or omit both to check local uncommitted changes.
- Actionable
insufficient_evidence(report schema v0.26).release_decision.evidence_coverage.evidence_gaps[]now lists one structured remediation row per low-confidence tool / source warning ({kind, subject, source_type, source_ref, why, next_action}), and scan writes an advisorysuggested-inventory.jsonskeleton next toreport.jsonwhenever low-confidence tools exist — in the same MCP-export shape everytool_inventoriesmanifest key loads. Pure projection of the existing coverage counts; thresholds, decisions, and fingerprints are unchanged. - Local capability-release ledger (
registryv0.1).agents-shipgate registry ingest --attestation <file>appends a normalized, content-addressed row to a JSONL ledger (idempotent);registry queryfilters by repo / verdict / capability id / trust-root flag. The v0 substrate for the cross-repo attestation registry; design boundary for any hosted aggregation documented indocs/hosted-plane-design.md, and the v1.0 report consolidation proposal indocs/report-v1-consolidation-rc.md. - Host capability governance v0 (
SHIP-HOST-BOUNDARY-*). New diff-aware, suppression-immune check family covering coding-agent host grants: MCP server additions/changes in.mcp.json/.cursor/mcp.json/.vscode/mcp.json, Claude Codepermissions.allowexpansion (wildcard-shaped rules likeBash(*)block; scoped expansions route to human review),permissions.denyremoval, hook changes, GitHub workflow permission expansion (write-allblocks; read→write routes to review), and newpull_request_targettriggers. Policy mirror atpolicies/host-boundary.shipgate.yaml; concepts and reviewer guidance indocs/mcp-governance.md. Trust-root classification now also covers.claude/settings.json/.claude/settings.local.json/.cursor/mcp.json/.vscode/mcp.json. audit --hostzero-config inventory. One read-only command that answers "what is my coding agent currently allowed to do in this repo?" — MCP servers (env keys only, never values), permission rules with wildcard flags, hooks, and workflow write scopes /pull_request_target— as one page of Markdown or--json. Works withoutshipgate.yaml.- Policy packs v0.2: conditional composition + org distribution.
matchgainsall_of/any_of/none_ofcombinators (flat fields stay implicitly ANDed — fully backward compatible) and parameter predicates gain declared-bound comparisons (maximum_above,minimum_below), so rules like "financial action with amount unbounded or above 1000 must declare approval" are now declarative.checks.policy_packsentries accept an optionalsha256content pin that fails the scan closed when a shared/org pack is tampered with. Schema frozen atdocs/policy-pack-schema.v0.2.json. - MCP server mode (optional
[mcp]extra).agents-shipgate mcp-serveexposesshipgate_preview,shipgate_verify, andshipgate_explain_findingover stdio so shell-less agents can query the verifier in-loop. Pure projection layer: no network, no mutating tools, no second gate (docs/mcp-server.md). - PreToolUse boundary hook for Claude Code.
install-hooks --target claude-codenow also registers aPreToolUsehook: editing a protected trust-root surface routes the tool call to the human (permissionDecision: "ask", ordenyviaAGENTS_SHIPGATE_PRETOOLUSE_DECISION) with an explanation — the authority boundary surfaces in-session, before the edit, instead of at PR time. The protected-surface list is rendered at install time from the verify check'sTRUST_ROOT_SURFACES, so hook and gate cannot drift. - Native GitHub Check Run support. New Action inputs
check_run/check_run_namepublish the merge verdict as a Check Run (mergeable→ success,blocked→ failure, human-routed verdicts → neutral) with up to 50 line-level annotations fromreport.sarif(scripts/github_check_run.py; requireschecks: write). New recipes:examples/github-actions/09-risk-labels-and-reviewers.yml(risk labels- trust-root reviewer routing from existing outputs) and
10-check-run-annotations.yml.
- trust-root reviewer routing from existing outputs) and
agent_weakens_gatefixture. One-command trust-root demo (agents-shipgate fixture run agent_weakens_gate): the head commit deletes the repo's Shipgate CI workflow — the cheapest reward-hack — and the verifier returnsmerge_verdict: blockedwithcan_merge_without_human: falsevia the suppression-immuneSHIP-VERIFY-CI-GATE-REMOVED/SHIP-CODEX-BOUNDARY-CI-GATE-REMOVEDchecks.- Privacy hardening. The redaction passthrough for already-redacted
values now honors only marker kinds Shipgate itself emits, so scanned
values formatted like
[REDACTED:...]can no longer smuggle payloads past forced sensitive-key redaction. Added symlink-escape regression tests for input loading andapply-patchescontainment. - Add a GitHub/verify
agent-result.jsonartifact that uses the existingagent_result_v1schema instead of introducing a second agent-result contract. The Action exposesagent_decision,risk_level,audit_id,required_reviewers, andpolicy_snapshot_sha256, and the opt-infail_on_decisionsinput now fails closed when configured but no compact agent decision is available. - Phase 7 makes capability diff the default verifier review primitive when a
reviewed base lock is committed:
verifyemits head capability locks plus semantic diff JSON/Markdown review artifacts when available, and attestation output moves from schema0.1to0.2to bind capability lock/diff hashes. - SARIF results now prefer stable policy rule IDs when a finding carries one,
while preserving the built-in Shipgate
check_idin properties. Existing GitHub code-scanning alerts keyed by the previous rule ID may close/reopen on the first upgrade run. - Add the repo's advisory self-dogfood Shipgate workflow, product-hardening gap-closure docs, Agent Workflow Evidence schemas, and the AgentPR Governance case catalog / acceptance spec.
-
Verifier adoption-loop release prep. Public docs and discovery metadata now lead with the verify-first adoption path, pinned
v0.11.0snippets, verifier artifacts, merge verdicts,fix_task, and explicit Action merge-policy examples. Adds the verify-nativeai_generated_refund_prfixture for the blocked refund PR demo and introduces the provisionalagents-shipgate feedback exportcommand plusdocs/feedback-schema.v0.1.jsonfor redacted design-partner feedback loops. -
Verifier PR comment v2 + additive Action outputs. The GitHub Action now defaults to the verifier workflow (
verify_mode: verify) and the capability-review PR comment (pr_comment_style: capability-review) for the next minor release. The comment starts fromrelease_decision.decision, renders a top capability-change table, surfaces trust-root warnings, separates required human/coding-agent work, and links the generated artifacts. The v1 findings-oriented comment remains available for one minor release cycle withpr_comment_style: findings.- New Action outputs are additive:
should_run,trigger_action,trigger_rule_ids,verifier_verdict,trust_root_touched,policy_weakened,capability_changes_added,capability_changes_modified, andcapability_changes_removed. - Existing outputs are preserved;
decisionremains the preferred release gating output. verifier.jsonnow includes a derivedcapability_reviewprojection overreport.capability_changeandreport.verifier_summary. It is reviewer-facing only and cannot disagree with the head scan'srelease_decision.
- New Action outputs are additive:
-
New large-scale sample + asserted latency budget. Adds
samples/large_multi_framework_agent/— a production-shape retail-ops AI assistant with ~65 tools across five tool sources (payments OpenAPI, fulfillment OpenAPI, CRM MCP, internal warehouse MCP, OpenAI Agents SDK). Exercises the pipeline (loaders → checks → release decision → reports + packet + privacy redaction) at realistic load, well beyond the 5–15 tool range covered by the existing samples. The manifest declares partial governance coverage on purpose so the scan surfaces a realistic mix of blockers, review items, and audit-envelope activity (~10 critical approval gaps, ~70 review items, severity overrides, suppressions, manual risk hints). Newtests/test_large_sample.py(12 cases) asserts:- Latency budget of 10.0 s wall-clock per scan (typical: 1–2 s on a 2024 laptop). The release gate lives on the CI critical path; a silent regression that doubles scan time would be felt by every adopter. The budget is generous to absorb CI variance — if the typical time exceeds half the budget, the sample has grown or the pipeline has regressed.
- Structural shape: all 5 sources contribute tools; tool count in
[50, 100]; findings in [40, 200]; decision blocked; at least one
critical
SHIP-POLICY-APPROVAL-MISSING; scope-coverage fires; severity-override audit envelope populated; contribution rules exhaustive over findings; privacy/reviewer/heuristics audit envelopes emitted. No committedexpected/report.{md,json}goldens (intentional — pinning 50+ findings × 20+ report sections through every schema bump is high cost, low signal). Auto-discovered asagents-shipgate fixture run large_multi_framework_agent; NOT added toself-check's default fixture set so install verification stays fast.
-
init --writenow ensuresagents-shipgate-reports/is gitignored. Closes a long-standing DX gap: the reports directory created by the firstscanwould silently appear ingit status(and could be committed by an agent runninggit add -A). On everyinit --writewe now also write a managed block to.gitignore:- File missing → created with just the block.
- File present without our markers and without an existing
agents-shipgate-reports/line → managed block appended (separated by one blank line; user content preserved byte-for-byte). - File present with our markers → upserted (unchanged / updated / migrated on version bump; refused on a newer version).
- File present with
agents-shipgate-reports/(or/agents-shipgate-reports/agents-shipgate-reports//agents-shipgate-reports/) already on its own line → no-op (already_present). Normalization mirrors what gitignore itself does: trailing whitespace is stripped (gitignore ignores it on patterns), but leading whitespace is not — a line likeagents-shipgate-reports/(one leading space) is a broken pattern that git does not honor, so we fall through and append our managed block. Mid-line#is not treated as a comment introducer (gitignore only treats line-leading#as a comment, soagents-shipgate-reports/ # legacy lineis a literal pattern that matches nothing — we again fall through and append). The same leading-whitespace rule applies to!-negations:!agents-shipgate-reports/is not honored by git, so we don't treat it asskipped_negatedeither. - File present with
!agents-shipgate-reports/→ no-op (skipped_negated). Explicit user opt-outs are respected. - File present with ambiguous markers (e.g. duplicate blocks) → no-op
(
skipped_ambiguous). Idempotent on both LF and CRLF hosts (CRLF is preserved when writing, and the marker regex tolerates a trailing\rso the secondinit --writerecognizes the existing block rather than appending a duplicate). Also runs when the manifest already exists so repos that adopted Shipgate before this CLI version get the line on their nextinit --write. Failure modes (symlinked.gitignorechain, path is not a regular file, write error) emit anerror/skipped_*outcome but never blockinit— exit code is unchanged from prior versions.
The outcome is surfaced in
--jsonoutput as a newgitignore: {status, path, message, block_version}field. A human-readable one-line message prints to stdout (or stderr for skip/error statuses);unchangedandalready_presentare quiet so the success path stays scannable. New module:agents_shipgate.cli.discovery.gitignore_block. New tests:tests/test_init_gitignore.py(48 cases covering pure parsing, upsert, variant detection, CRLF parse + two-run CRLF idempotency, mid-line-#no-stripping, leading-whitespace rejection (space + tab + on negations), trailing-whitespace acceptance, and end-to-end through the CLI). -
MVP readiness polish. Check metadata now carries public
mvp_tiertriage labels; the OpenAI Agents SDK static extractor can scan a directory of immediate*.pyfiles; and CLI / GitHub summaries lead with the baseline-aware decision, headline, evidence coverage, and next action.mvp_tieris metadata only. It does not affect check execution, severity, fingerprints, baselines,release_decision, or CI exit behavior.- OpenAI Agents SDK single-file and directory modes now both emit
manifest-relative POSIX
source_refvalues. The extractor delegates to the shared Python static helper, so runtime/context parameters namedself,cls,ctx,context,config,runtime,run_manager, orcallbacksare omitted from normalized input schemas. - CLI top findings now show the highest-impact 3 active findings, prioritized
by release blockers then review items.
list-checksplain text includesmvp_tieras a third tab-separated column; use--jsonfor stable programmatic consumption.
-
v0.21 —
--no-heuristicsCLI flag closes the round-3 / round-4 E5 carryover.Finding.provenance_kindhas shipped on every report since v0.15 as required+non-nullable wire metadata but had no consumer for four review cycles. v0.21 lands the consumer the field was always designed for: a security/GRC-friendly filter that excludes findings whose provenance iskeyword_heuristicorregex_heuristicfrom the active release-gating set.- New
--no-heuristicsflag onagents-shipgate scan(stable in 0.x). When set, findings whoseprovenance_kindis inNO_HEURISTICS_EXCLUDED_PROVENANCE_KINDS(today:keyword_heuristicandregex_heuristic) are markedsuppressed=Truewithsuppression_reason="filtered by --no-heuristics"BEFORE the release decision is built. Filtered findings remain infindings[]for transparency; they no longer gate release. The KEEP list isstatic_declaration,ast_extraction, andpolicy_pack— declared/parsed-shape findings and explicit external rules stay in scope. - New top-level
report.heuristics_filteraudit envelope. Required + always present on emitted scans regardless of whether the flag was set (parallel toprivacy_auditshape). Fields:enabled,excluded_provenance_kinds: list[str],filtered_finding_count,filtered_by_kind: dict[str, int]. Earns the contract weight ofFinding.provenance_kindby giving it a first-class consumer. - Manifest-driven suppression wins on overlap: a finding the user
explicitly suppressed via
checks.ignorekeeps the user's reason text even when its provenance_kind would have triggered the filter. The audit envelope still counts the overlap so reviewers see the filter's effective scope. ReviewerSummarylens/audit counts already reflect the post-filter active set (the filter runs beforebuild_reviewer_summary); no new field added toReviewerSummary— the dedicated envelope is the right audit home.- Schema bump:
report_schema_version: "0.20"→"0.21". v0.20 moves to frozen-reference; existing v0.20 consumers ignore the new field. - Contract-stamp pin in
docs/architecture.mdbumped to date2026-05-23, reportv0.21, packetv0.6(unchanged). Thetest_architecture_doc_contract_stamp_matches_runtimeregression test moves in lockstep. - 12 new tests in
tests/test_no_heuristics.pycovering: pure- function filter semantics (KEEP / FILTER classifications per provenance_kind), envelope shape parity across enabled=True/False, manifest-suppression preservation, contract-list completeness (every value inNO_HEURISTICS_EXCLUDED_PROVENANCE_KINDSis a realProvenanceKind; KEEP+EXCLUDE partition is exact), end-to- endrun_scan(no_heuristics=True), CLI subprocess smoke test, monotone-non-increasing reviewer-summary lens counts under filtering. - Decision recorded. Round-4 review's E5 carryover offered ship-
or-retire on
provenance_kind. We ship. Retiring would have forced a deprecation cycle on a stable-contract field used by every report since v0.15; shipping the flag earns the weight and serves a real audience (security/GRC reviewers triaging declared-only findings before promotion).
- New
-
v0.21 — CI coverage gate raised from 75% → 85% (E7 from round-4 review). Both
.github/workflows/ci.ymland.github/workflows/release.ymlnow pass--cov-fail-under=85. Aggregate coverage onmainat the time of the bump is ~88%, so the gate is +10pp tighter with ~3pp headroom for day-to-day movement. The bump catches the next time a refactor lands materially less-covered code without corresponding tests. No source change required to land — the gate is simply closer to the actual signal. Per-file coverage is not enforced; the aggregate floor only rises in step with what's already proven onmain. -
v0.21 — decompose
inputs/n8n.pyintoinputs/n8n/package (E8 from round-3 review). The largest input adapter (1493 lines monolithic) is now a 6-module package with per-concern boundaries; the public surface (N8nAdapter,load_n8n_artifacts) is unchanged via__init__.pyre-exports. No behavior change — all 30tests/test_n8n.pycases pass byte-identical; M3 trust-lint passes; M5 plugin validation passes; adapter-discovery contract test (PR #111) passes._common.py(300 LOC) — constants (N8N_NODE_TYPE_RE,FROM_AI_RE,N8N_SOURCE_TYPES,BUILTIN_N8N_PREFIXES,HTTP_METHODS),_NodeItemand_Edgedata classes, leaf string / path / hash / redaction helpers, node-kind classification._secrets.py(122 LOC) — secret scanning of parameters / notes /pinData/staticDataagainst the v0.19 globalSECRET_PATTERNSlayer._auth_risk.py(148 LOC) — credential references,AuthInfosynthesis, risk-hint heuristics, HTTP path hint._tools.py(492 LOC) — Tool extraction for the 5 flavours (ai, workflow, code, http, mcp_client) + projectedmcp, schema extraction ($fromAI(...)macro,inputSchema,outputSchema,parameters.fields), MCP Client Tool selection mode, tool-artifact recording._workflows.py(464 LOC) — workflow file loading, shape detection,_extract_workfloworchestrator, connection-graph edges, node-record builders, dynamic-surface emission._adapter.py(249 LOC) —N8nAdapter,load_n8n_artifacts, and auxiliary loaders (_load_inventory_ref,_load_credential_stubs,_load_structured_refs,_artifact_paths,_credential_entries).- Dependency direction is a DAG at module-load time:
_common ← _secrets, _auth_risk ← _tools ← _workflows ← _adapter._toolscalls back into_workflowsfor record builders and dynamic-surface emission via late imports inside the call sites that need them — keeps the static import graph one-way. tests/test_public_surface_contract.py::test_supported_inputs_match_adapter_class_vars_bidirectionallyupdated fromglob("*.py")torglob("*.py")so adapter sub-packages are scanned (the contract test was written when n8n was a single file).- Closes round-3 evolution item E8; brings the largest input adapter
in line with the typical adapter file size (mcp.py 148, openapi.py
343, langchain.py 305). Largest sub-module now is
_tools.pyat 492 LOC.
-
Adoption kit rendering externalized. Codex and Claude Code
--agent-instructionsskill bundles now render from packagedadoption-kits/files instead of Python string constants. Downstream repos can provide.agents-shipgate/adoption-kit.yamlor--agent-instructions-kit <path>for local overrides, and generated skill directories now carry.agents-shipgate-kit.jsonsidecars for managed migrations. -
v0.20 — third-party adapter entry-point discovery (E4 from round-3 review). Opens the same extension surface for adapters (input loaders) that M5 already opened for check plugins. Discovery is gated by the existing
AGENTS_SHIPGATE_ENABLE_PLUGINS=1env var and--no-pluginsCLI flag.- New entry-point group:
agents_shipgate.adapters. A third-party package declares an adapter class (or instance) in itspyproject.tomlunder[project.entry-points."agents_shipgate.adapters"]; the class must satisfy theToolSourceAdapterProtocol —source_typeClassVar,scopeClassVar (per_sourceorper_scan),artifact_classClassVar, and aload(source, base_dir, manifest)method. - New module
src/agents_shipgate/inputs/adapter_validation.pywith four load-time gates:load_failed,bad_protocol,bad_scope, andsource_type_collision— the load-bearing trust rule rejecting any third-party adapter whosesource_typeshadows a built-in or another already-registered third-party adapter. - New top-level
discover_third_party_adapters(registry, *, plugins_enabled, loaded_adapters)ininputs/protocol.pywalksentry_points("agents_shipgate.adapters"), validates each entry, and registers the valid ones onto the supplied registry. Both valid and invalid records surface inreport.loaded_adapters[]so reviewers can see what was skipped. - New report field
loaded_adapters: list[dict[str, Any]]parallel toloaded_plugins[]. Items carryname,value,distribution,version,source_type,validation_status,validation_errors[],runtime_errors[]. Required + present on every emitted scan (empty list when--no-pluginsor no third-party adapters are installed). The schema generator marks each item's eight fields as required. --strict-plugins(v0.17+) extended to cover adapter failures. Any non-validloaded_adapters[]row OR non-emptyloaded_adapters[].runtime_errorsnow elevates the scan to exit code 4 alongside the existing plugin failures.--no-pluginsflag help text updated to mention third-party adapter discovery is also disabled.run_validated_adapter(inadapter_validation.py) provides a runtime safety wrapper for callers that want to capture exceptions intoloaded_adapters[].runtime_errorsinstead of propagating them. The dispatcher's existing_absorbartifact- class check already firesTypeErrorfor artifact smuggling; runtime wrapping is opt-in for future adapter-execution paths.- 21 new tests in
tests/test_adapter_entry_point_discovery.py: each of the four gates + valid-class + valid-instance + env-var gating +--no-pluginsoverrides + collision-with-each-builtin parametrize + collision-between-third-parties +--strict-pluginsend-to-end + runtime safety net (exception capture, wrong return type, artifact smuggling). - STABILITY.md gains a new "Third-party adapter discovery (v0.20+)"
subsection under "Trust-model invariants" documenting the four
gates + the
source_type_collisionload-bearing rule.
- New entry-point group:
-
v0.20 — top-level
reviewer_summaryblock. Adds a deterministic projection of the reviewer lens surfaces (tool_surface_diff, capability/intent diff,action_surface_diff, evidence matrix) and audit envelopes (policy_audit,privacy_audit, baseline integrity findings). Parallels v0.12'sagent_summaryfor the reviewer side:agent_summaryanswers "what should an agent do next?" andreviewer_summaryanswers "what should a reviewer look at first?".- Schema: bumped
report_schema_version0.19 → 0.20. The new block is required + non-nullable on the wire (Pydantic-Optional only for legacy test helpers). v0.19 schema is preserved atdocs/report-schema.v0.19.json. - Fields:
verdict(mirrorsrelease_decision.decision),headline(≤200 chars, PR-comment-friendly), per-lens activity counts (tool_surface_changes,capability_misalignments,action_surface_changes,evidence_matrix_gaps), per-audit counts (severity_overrides_applied,severity_overrides_tier_crossed,privacy_redactions,baseline_integrity_issues), andfirst_recommended_surface(deterministic pointer ornullon a clean scan). first_recommended_surfacepriority: blocked → release_decision, insufficient_evidence → release_decision, then action_surface_diffbaseline_integrity > tier-crossed policy_audit > capability_intent_diff > tool_surface_diff > privacy_audit > evidence_matrix > null. Encoded in
_pick_first_recommended_surfaceand pinned bytest_reviewer_summary.py.- Projection invariants: pure (no I/O, no LLM calls), deterministic
(same inputs → byte-identical output, asserted by
test_build_reviewer_summary_is_deterministic), cannot disagree with the underlying lens/audit data. - STABILITY.md + docs/agent-contract-current.md: new bullets +
enum-additivity rule mirroring
agent_summary.verdict.
- Schema: bumped
-
Docs: refresh
docs/architecture.mdto v0.19 reality. The doc was stuck at pre-v0.6 conceptually — it describedcore/models.pyas the shared model home (deleted in PR #95), framed adapters as free-functionload_<name>_artifacts(...)(pre-v0.11 pattern), and did not mention theschemas/layer, the five reviewer lenses (tool surface / capability-intent / action surface / policy audit / evidence matrix), the three audit envelopes (policy audit, privacy audit, baseline audit log), the AST trust lint, plugin validation gates, severity-override floor, baseline integrity, or the privacy redaction layer. Refresh covers the v0.19 pipeline end-to-end, names every module, cross-links toSTABILITY.mdfor each contract, and pins exit code6(strictbaseline verifyfailure). No code change. -
v0.18 / PR #1 trust-hardening:
dynamic_defaultcontract inCheckMetadata. Formalizes the M1 dynamic-severity contract closed in v0.17.CheckMetadata.dynamic_default: bool = Falseopts a check into the swing-severity category — its emitted finding severity depends on user-declared manifest values rather than the static catalog default. The severity-override resolver must receive the manifest-effective default viaextra_known_check_defaults; otherwise tier-crossing comparison runs against the static catalog default and an aggressive override can silently bypass the gate.- A new model validator rejects
dynamic_default=Truewithoutfloor_severity— a swing check without a floor has no safety net. SHIP-ACTION-POLICY-VIOLATIONnow declaresdynamic_default=Trueandfloor_severity="medium". Two distinct contracts apply to existing manifests; both produce loudConfigError(exit 2):- Hard floor (no bypass). Manifests resolving the check below
medium— i.e., toloworinfo— are rejected by thefloor_severityvalidator.acknowledge_overridesdoes NOT bypass the floor; the only remedies are to raise the override tomediumor above, or remove the override entirely. - Tier-crossing requires ack. Downgrading from the catalog
default
highto the floormediumcrosses the high → normal tier boundary. This case is allowed only with anacknowledge_overridesentry that supplies a reason; without one it is rejected with a tier-boundary error (not a floor error). Manifests currently overridingSHIP-ACTION-POLICY-VIOLATIONtolow/infocannot fix the regression by adding an ack — they must raise the override severity. Manifests overriding tomediumwithout an ack pass once the ack is added.
- Hard floor (no bypass). Manifests resolving the check below
cli/scan.py:_dynamic_check_defaultsis the new canonical aggregator. It seeds every catalog check carryingdynamic_default=Truewith its static default (step 1), overlays manifest-effective values for action-surface policies (step 2), and adds policy-pack rule IDs (step 3). The seed loop guarantees the resolver's internal-consistency guard cannot false-positive on user input that overrides a swing check without declaring the corresponding manifest section.- A contract test
test_dynamic_default_aggregator_completenessfails the moment someone adds a newdynamic_default=Truecatalog entry without ensuring the aggregator covers it. - Future checks emitting at manifest-declared severity must (A) set
dynamic_default=TrueinCHECK_METADATAand (B) add an aggregator overlay branch incli/scan.py:_dynamic_check_defaults. The contract test enforces both.
-
v0.18 / PR #1 plugin gate:
dynamic_default_not_supported.- New plugin-validation status rejects plugins declaring
AGENTS_SHIPGATE_METADATA.dynamic_default=True. Plugins have no path into the scan dispatcher's aggregator and so could never receive the manifest-effective default needed for tier-crossing comparison; emitting at that severity directly is the supported path (with the floor contract still applying viaCheckMetadata.floor_severity). - The gate runs before
_coerce_metadata()so a plugin declaringdynamic_default=Truewithoutfloor_severitylands indynamic_default_not_supportedrather than being mis-classified asbad_floorby the newCheckMetadatamodel validator.
- New plugin-validation status rejects plugins declaring
-
v0.18 / PR #2 review follow-up: per-call-site allowlist pinning. PR #91 review caught two structural holes in the v0.18 trust lint extension:
- P1: the allowlist matched on
(relative_path, surface)only, so one entry blanket-permitted every occurrence of a surface in a file. A future unreviewedsubprocess.run(...)added to an already-allowlisted file would slip past silently. - P2:
importlib.resourceswas globally exempted, sofiles(name)calls produced no violation. The current uses pass a literal'agents_shipgate'anchor, but a future user-controlled anchor would bypass the dynamic-import lint.
Both are closed by tightening the allowlist contract:
AllowedExceptionnow carriesline: intandsnippet: str(canonicalast.unparseof the offending node) in addition torelative_pathandsurface._violation_allowedmatches on all four fields. Adding a newsubprocess.runcall to an already-allowlisted file now requires a new entry; changing an existing call's argv shape changes thesnippetand fails the contract test.importlib.resources.joinsFORBIDDEN_ATTR_CALL_PREFIXES, andimportlib.resourcesjoinsTRACKED_NON_FORBIDDEN_MODULES. The earlier draft of this PR only forbadeimportlib.resources.files, which leftread_text,read_binary,path,open_text,open_binary,is_resource,contents,as_file, and any future addition under the module as a parallel bypass — each takes the same anchor-package argument and would have been silently allowed. The prefix entry catches the whole family.from importlib.resources import <attr>; <attr>(...)andimport importlib.resources as res; res.<attr>(...)both resolve to canonicalimportlib.resources.<attr>and trip the prefix. Both first-party call sites intriggers.pyandfixtures.py(currentlyfiles-only) are individually pinned with the literal'agents_shipgate'anchor in the snippet — a futurefiles(some_user_anchor)orread_text(some_user_anchor, ...)call would change the snippet and fail the test.Violationgainssnippet: strcaptured viaast.unparse(node).- New regression test
test_allowed_exceptions_pin_subprocess_run_per_call_siteasserts that multi-call files (triggers.py, artifacts.py) have distinct entries per call site, so the P1 bypass cannot reappear via consolidation. - New regression test
test_allowed_exceptions_have_no_duplicatesasserts no two entries cover the same call site. - Negative-control: injecting a 4th
subprocess.runintotriggers.pynow fails the contract test with the precise(line, surface, snippet)triple. Injectingfiles(user_var)in place offiles('agents_shipgate')fails similarly.
- P1: the allowlist matched on
-
v0.18 / PR #2 trust-hardening: static AST lint widened to entire scanner. Previously
tests/test_adapter_static_only.pyAST-scanned onlysrc/agents_shipgate/inputs/; the public claim in STABILITY.md and README is broader ("the scanner does not execute or import user code"). The lint now structurally enforces the broader claim.- Scope widened: scanner now walks every
.pyfile undersrc/agents_shipgate/viarglob. The legacytest_invariant_lint_covers_every_adapter_modulewas paranoid for the 18-fileinputs/case and no longer scales to ~80 files — the new contract testtest_no_unallowlisted_forbidden_surface_in_scanneris the replacement, asserting a definitive PASS/FAIL signal over the whole sweep. - Four legitimate first-party meta-CLI surfaces are allowlisted via a
new
ALLOWED_EXCEPTIONStuple ofAllowedExceptionentries, each with prose rationale:cli/bootstrap.pysubprocess.run(...)— chainsdetect → init → scan → apply-patchesagainst Shipgate's own CLI.cli/discovery/artifacts.pysubprocess.run(["git", ...])— probes the user repo for file inventory.triggers.pysubprocess.run(["git", "diff", ...])— trigger evaluation reads diff content.cli/self_check.py__import__(name)— validates that supplied modules are installed. Runs only underagents-shipgate self-check.
- Two contract tests prevent allowlist rot:
test_allowlist_entry_matches_real_surface(every entry must correspond to a real surface) andtest_no_unallowlisted_forbidden_surface_in_scanner(every forbidden surface must be allowlisted or eliminated). importlib.resourcesadded toALLOWED_FORBIDDEN_MODULE_IMPORTSfor bundled-package files (e.g.fixtures.py,triggers.py).importlib.metadataremains allowed for plugin/adapter discovery._scan_sourcenow returns structuredViolationobjects (line,surface,message) instead of preformatted strings, so callers can route bysurfaceagainstALLOWED_EXCEPTIONS.- STABILITY.md "Trust-model invariants" widened to cite the entire scanner package and adds a "Meta-CLI surfaces (allowlisted, audited)" subsection documenting each of the four entries.
- Scope widened: scanner now walks every
-
v0.17 / M1 trust-hardening: severity-override floor + audit.
core.models.CheckMetadatagains an optionalfloor_severityfield (Severity | None). 16 release-critical built-in checks now declare a hard floor:SHIP-POLICY-APPROVAL-MISSING(critical → floor "high")SHIP-ACTION-{FINANCIAL-WRITE-CONTROL-MISSING, DESTRUCTIVE-ROLLBACK-MISSING, WILDCARD-SCOPE, EFFECT-ESCALATED, APPROVAL-REMOVED}(critical → floor "high")SHIP-AUTH-{MISSING-SCOPE, MANIFEST-BROAD-SCOPE, TOOL-BROAD-SCOPE, SCOPE-COVERAGE-MISSING}(high → floor "medium")SHIP-SCOPE-{TOOL-OUTSIDE-PURPOSE, PROHIBITED-TOOL-PRESENT}(high → floor "medium")SHIP-INVENTORY-{WILDCARD-TOOLS, LOW-CONFIDENCE-PRODUCTION-SURFACE}(high → floor "medium")SHIP-POLICY-CONFIRMATION-MISSING(high → floor "medium")SHIP-SIDEFX-IDEMPOTENCY-MISSING(high → floor "medium")
- Any
checks.severity_overridesentry that resolves below the floor is rejected as a manifest config error (exit 2). The floor is hard; no acknowledgement bypasses it. Breaking for manifests that previously downgraded these checks below their new floor — fix by raising the override to floor-or-above, or removing the override. checks.severity_overridesaccepts both the legacy scalar form (SHIP-XYZ: medium) and a new rich form (SHIP-XYZ: { severity, reason, expires }). Reason flows into the new audit row; expires gives reviewers a time-bounded override.- New
checks.acknowledge_overrides[]block. Required for any severity override whose application crosses a severity tier boundary (critical ↔ high, high ↔ medium/low/info) as a downgrade. Tier-crossing upgrades never require ack (strictly more conservative). Same-tier downgrades (medium → low) don't require ack. For checks emitted with manifest-declared severity (action-surface policies viaSHIP-ACTION-POLICY-VIOLATION, policy-pack rules) the resolver compares against the strongest declared severity across the manifest, not the static catalog default — so aseverity: criticalaction policy with overridehighis correctly tier-crossing and requires ack. - Expired
acknowledge_overridesentry raises a manifest config error (exit 2) — no advisory-mode bypass. Same hard contract applies toexpireson rich-formseverity_overridesentries. - New top-level
report.policy_auditblock surfacing every applied override:policy_audit.severity_overrides_applied[].{check_id, default_severity, applied_severity, manifest_path, reason, tier_crossed, direction, expires}. Always emitted on scans (empty envelope when no overrides applied); required + non-nullable on the wire (mirrors the v0.12agent_summarypattern). Lands atreport_schema_version: "0.17"alongside M8'srelease_decision.contribution_rules[]— both audits are additive and share the same schema bump. - Markdown report renders a new "Policy Audit" section between Release Decision and Summary when overrides exist. GitHub step summary adds a one-liner counting overrides + tier-crossed + upgrades/downgrades.
- New module
core/severity_overrides.pyowns floor/tier/ack/expiry resolution as a pure function;core/findings.py::apply_severity_overridesstill consumes a flatdict[str, Severity]so existing direct callers and tests stay byte-compatible. AgentsShipgateManifest.severity_overrides()still returns the flat scalar projection for back-compat; newseverity_override_entries()returns the rich shape andacknowledge_overrides()returns the ack list.
-
Added
release_decision.contribution_rules[]— a deterministic per-finding audit of how each finding contributed to the release decision (M8 of the Trust Hardening Pass). Bumpsreport_schema_versionto0.17(shared with M1'spolicy_audit). Exactly one row perreport.findingsentry (including suppressed) withcategory∈{blocker, review_item, excluded}andrule∈{policy_block_new, severity_block_new, policy_baseline_accepted, severity_baseline_accepted, review_required, sub_threshold, suppressed}. The newSTABILITY.md"Release decision truth table" documents which(rule, category)pair fires for every(blocks_release, severity, baseline_status, fail_on)combination. Additive only: no semantic change todecision,blockers[],review_items[],fail_policy.exit_code, or strict-mode exit codes — the audit reflects existing behavior, it does not modify it. The field defaults to[]for legacy reports loaded viaexplain-findingso consumers never need an existence check. -
Replaced the hardcoded
if/elifsource-dispatch incli/scan.pywith a realToolSourceAdapterProtocol andAdapterRegistry. Every loader (MCP, OpenAPI, OpenAI Agents SDK, Google ADK, LangChain, CrewAI, n8n, Codex plugin, OpenAI API, Anthropic API) is now an adapter class that registers withagents_shipgate.inputs.protocol.REGISTRY. The scan pipeline returns a typedArtifactBagso framework artifacts retain their concrete types intoScanContext. Framework adapters now fire correctly when configured via top-level manifest sections without a matchingtool_sourcesentry. Internal refactor — no behavior change for users. -
Added minimal source provenance to findings.
agents-shipgate scannow emitsreport_schema_version: "0.11"with optional structured location keys onfindings[].source:path,start_line,end_line,start_column, andpointer(RFC 6901). Populated for the common tool-source loaders (OpenAPI, MCP, OpenAI tool artifacts, Anthropic tool artifacts) when the source file is YAML; JSON inputs carrypathandpointerbut no line. SARIF emits the position viaphysicalLocation.region.startLine(andendLine/startColumnwhen present), with the JSON pointer underproperties.shipgatePointer. Capability-Intent Diff markdown appends(at path:line)to misalignment rows when provenance is available.run_idexplicitly excludes the new provenance fields so YAML line drift cannot churn the hash. Reports without populated provenance remain byte-identical to v0.10 becausereport_json_payloadstrips unset keys. -
Added JSON-first tool-surface diff for PR review.
agents-shipgate scannow emitsreport_schema_version: "0.10"with always-presenttool_surface_factsandtool_surface_difffields. The diff explains added/removed/changed tools, high-risk tag changes, scope drift, enforcement control changes, policy drift, finding deltas, and accepted debt without changingrelease_decision.decision, strict/advisory exit behavior, or SARIF. -
Added
agents-shipgate scan --diff-from <path>for comparing against a priorreport.jsonor v0.3 baseline JSON.--baselinestill controls finding baseline status and strict-mode filtering;--diff-fromcontrols onlytool_surface_diff. -
Baseline files now save as schema
0.3with optionaltool_surface_facts. Schema0.2baselines continue to load for accepted-debt matching but cannot enable surface diff by themselves. -
GitHub Action adds
diff_from,diff_base, anddiff_enabled. Settingdiff_base: targetperforms a best-effort target-branch scan with the PR-side installed package and falls back to a disabled diff note on fetch, config, schema, or scan failures. -
Release Evidence Packet schema bumped to
0.2with a compacttool_surface_diffsection derived from the report JSON. -
Added optional manifest-level HITL validation evidence mode under
validation:. The scanner now reads local approval traces, override logs, high-risk auto-approval exclusions, and promotion criteria to structure evidence gaps for reviewers; it does not generate those runtime artifacts or certify readiness. -
Tightened HITL evidence wording and provenance.
SHIP-EVIDENCE-*findings now describe missing or incomplete local review evidence without implying runtime controls are absent, and include deterministicevidence.source_provenance[]entries.source_provenanceis excluded from finding fingerprints, so adding provenance does not rotate existing HITL baselines or suppressions. -
Release Evidence Packet schema bumped to
0.3withhuman_in_the_loop.runtime_control_disclaimer,human_in_the_loop.source_provenance[], andhuman_in_the_loop.provenance_mode. -
Added
samples/hitl_evidence_covered_agent, a refund-domain fixture with local approval trace, override log, high-risk exclusion, and promotion criteria evidence. -
Added four
SHIP-EVIDENCE-*checks. Existing baselines may surface these as new findings after upgrade when a manifest opts intovalidation:. -
Add
agents-shipgate scenario suggest(target:0.9.1), a YAML export that fans outreport.json.suggested_scenarios[]into concrete per-finding/per-tool dynamic validation steps. -
Added ranked next-action diagnostics:
detect --jsonanddoctor --jsonnow emitdiagnostics: [...]andnext_actions: [...]blocks alongside the existing single-stringnext_actionfield. Coding-agent callers can recover from common first-run failures (missing manifest, zero tools, unresolvedCHANGE_ME, missing source files, MCP/OpenAPI artifact-only workspaces, dynamic toolsets, production targets without permissions, and three negative-control cases) without consulting human-facing docs. Errors emitted underAGENTS_SHIPGATE_AGENT_MODE=1carry the samenext_actionsarray. Diagnostic catalog and schema in docs/diagnostics.md. -
Behavior change: when a required
tool_sources[].pathdoes not resolve (file missing OR resolves outside the manifest directory),agents-shipgate doctor --jsonexits 0 withunresolved_sources: [...]and aSHIP-DIAG-MISSING-SOURCE-FILEdiagnostic so an agent gets a routable next action. The non-JSONagents-shipgate doctorform prints the same diagnostic in human-readable form and exits 3 so interactive users still see a loud failure.agents-shipgate scanis unchanged — it still raisesInputParseError(3)on the same condition regardless of--json. -
DetectResultgains aworkspace_signalsblock (Python file count,pyproject.toml/requirements.txtpresence, conventional dir hits) used by the new diagnostic resolvers to discriminate negative-control cases. The block is additive; existing fields are unchanged.
- Report schema bumped to
v0.8. New top-level requiredrelease_decisionblock:{decision, reason, blockers, review_items, evidence_coverage, baseline_delta, fail_policy}.decisionis one of"blocked" | "review_required" | "passed"and is the recommended release-gate signal for v0.8+ consumers.blockersandreview_itemsare reference-only entries (id, fingerprint, check_id, severity, title, baseline_status) — full Finding payloads stay infindings[].release_decisionis baseline-aware: matched criticals appear inreview_items(accepted debt), notblockers. Critical severity is policy-independent — even advisory CI surfaces a new critical as a blocker (withwould_fail_ci=false).release_decision.fail_policy.exit_codematches the process exit code one-for-one across allci_mode×fail_on×--baselinecombinations.
summary.statusis preserved byte-for-byte for backwards compatibility with v0.7 consumers. It stays baseline-blind (a baseline-matched critical still flips status torelease_blockers_detected). The intentional divergence fromrelease_decision.decisionis documented in STABILITY.md.docs/report-schema.v0.8.jsonadded;v0.7.jsonretained as a frozen reference. JSON-schema validation catches missingrelease_decisionon any emitted report.- Markdown / GitHub Action / CLI summaries now lead with the Release Decision block (Decision → Reason → Blockers → Review items → Evidence coverage → Baseline delta → Fail policy). SARIF output is unchanged.
- GitHub Action exposes four new outputs:
decision,blocker_count,review_item_count,ci_would_fail. Existing outputs (status,critical_count,baseline_*,adk_*,report_*,exit_code) unchanged. - The release verdict path remains deterministic and LLM-free: no agent execution, tool call, model call, MCP connection, network access, or telemetry is added for v0.8.
exit_code_for_report()refactored to shareeffective_fail_on()andbaseline_filtered_active()helpers withbuild_release_decision(), so the standalone exit code andrelease_decision.fail_policy.exit_codecannot drift. New regression test pins this across the matrix.
Adoption activation: makes the v0.6 features visible to humans and AI coding agents on real repos, plus exposes per-check remediation metadata so agents can route findings without re-walking the catalog.
- Agent-facing docs surface:
- New "Should I run Shipgate on this PR?" trigger table in
AGENTS.mdwith the soft-stop rule (don't skip MCP/OpenAPI-only repos that surface asis_agent_project: false). - New
docs/agent-recipes.md— copy-pasteable AI-agent workflows for the canonical 4-call flow. - New
docs/autofix-policy.md— four classes (safe / medium / manual / never), catalog-vs-Finding contract, strict derivation rule, three patch states, unknown-check-id fallback,apply-patches --confidencetable, decision tree. - New
docs/minimal-real-configs.md— per-framework references to runnablesamples/*fixtures (no inline snippets to drift). docs/INDEX.mdcleanup: stalereport-schema.v0.5.jsonlink removed; current schema link nowreport-schema.v0.7.json.docs/quickstart.mdadds a "second 60 seconds" real-repo path.
- New "Should I run Shipgate on this PR?" trigger table in
CheckMetadataextensions:- New
autofix_safe,requires_human_review,suggested_patch_kindfields on every check (45 entries).docs_urlpopulated for every check pointing at a stable### SHIP-...anchor indocs/checks.md. 7 new per-check sections added todocs/checks.mdso every check has a stable anchor. - Catalog-level safety bools stay conservative — even checks whose
generator usually produces a safe non-manual patch (stale-manifest
removals, scope coverage) keep
autofix_safe: false/requires_human_review: truebecause the generator can fall back toManualPatchin edge cases (ambiguous duplicates, etc.).suggested_patch_kindis informational — describes what the generator targets when conditions are clean.
- New
Findingextensions + derivation:- Same four optional fields on every Finding, populated by
annotate_remediationduring scan. Three patch states handled distinctly:patches: None(no--suggest-patches) → seed from CheckMetadata; safe-closed fallback for unknown check IDs (policy packs, third-party plugins).patches: [](--suggest-patches ran but generator emitted nothing) → safe-closed shape withsuggested_patch_kind: "none". Does NOT fall back to catalog (the report carries no patches).patches: [...](non-empty) → strict derivation rule:autofix_safe: trueONLY when EVERY emitted patch is non-manual AND high-confidence. Mixed states fall to safe-closed.
docs_urlalways sourced from CheckMetadata (patches don't carry per-instance documentation URLs).
- Same four optional fields on every Finding, populated by
- Report schema bumped to
v0.7per STABILITY.md ("report_schema_versionbumps minor on additive changes").docs/report-schema.v0.7.jsonadded;v0.6.jsonretained as a frozen reference. _run_idexcludes the four new derived fields pluspatchesso toggling--suggest-patches(or future enrichment fields) doesn't shift the hash. New regression test pins this.- Plugin-loading isolation: every code path that reads the catalog
during scan honors the scan's
plugins_enabledsetting, including the_attach_patchesrecommendation lookup.AGENTS_SHIPGATE_ENABLE_PLUGINS=1 agents-shipgate scan --no-pluginsno longer loads plugins. - Onboarding prompt rewrite:
prompts/add-shipgate-to-repo.mdnow leads with the canonical 4-call flow (detect → init --write --ci → scan --suggest-patches → apply-patches --json) and includes the decision tree fromdocs/autofix-policy.md. Soft-stop rule documented inline.apply-patches --jsonflag added so the reporting step has structured data to read. - Dual-copy prompt parity: byte-identical mirror between
prompts/andskills/agents-shipgate/prompts/enforced bytests/test_prompt_parity.pyso the two surfaces can't drift. - Test coverage: 314 tests pass. New test files:
tests/test_remediation_metadata.py,tests/test_finding_remediation.py,tests/test_docs_links.py,tests/test_prompt_parity.py,tests/test_v07_metadata_roundtrip.py.
Agent-friendly adoption: compresses Shipgate setup into a single tool-using turn for AI coding agents.
- Added
agents-shipgate detect— read-only command that classifies a workspace as an agent project and reports which framework(s) it uses, with confidence and per-framework evidence. agents-shipgate initnow auto-detects by default. Generated manifests are schema-valid (validated before write) and include framework-specific tool sources and config blocks (LangChain, CrewAI, Google ADK, OpenAI Agents SDK, Anthropic, OpenAI API). The legacy CHANGE_ME-heavy template is preserved under--minimal.- Added
agents-shipgate init --ci— opt-in flag that writes.github/workflows/agents-shipgate.yml. Orthogonal to--write: each gets its own overwrite-refusal check. Detects cross-workflow shipgate references and skips with a distinct message. - Added
agents-shipgate scan --suggest-patches— attaches Patch objects to every active finding (machine-applicable for the safe subset; ManualPatch for everything else).Finding.patchesis absent when the flag is not set; non-opting JSON consumers see no contract change. - Added
agents-shipgate apply-patches— applies patches from a scan JSON report. File-grouped, single SHA per file, dry-run by default, containment-checked against the report's newmanifest_dirfield. - v0.6 patch generators (manifest-target only):
- High-confidence
RemovePointerPatchfor the 3 stale-manifest checks (SUPPRESSION, POLICY, RISK-OVERRIDE). - Medium-confidence
AppendPointerPatchforSHIP-AUTH-SCOPE-COVERAGE-MISSING(NOT applied at default--confidence high— adding scopes can encode policy choices). - Permanent
ManualPatch(with anti-pattern instructions) forSHIP-API-TRACE-{APPROVAL,CONFIRMATION}-MISSING— flipping approved/confirmed in a trace patches the evidence, not the agent.
- High-confidence
- Bumped report schema to v0.6 (additive: optional
Finding.patchesarray; new top-levelmanifest_dir). v0.5 schema retained for reference. - Anthropic-specific glob coverage in
init: tools and policies matchingtools/anthropic-tools.jsonandpolicies/anthropic-policy.yamlnow populate theanthropic:block automatically. - Added end-to-end agent task
02_three_command_flowexercising the fulldetect → init → scan → apply-patchespipeline. - Added
ruamel.yaml>=0.18as a dependency for round-trip-preserving YAML edits inapply-patches.
- Polished launch-facing docs after the v0.5.0 release.
- Updated active examples and discovery metadata to the v0.5.1 release tag.
- Added curated launch marketing and presentation assets while excluding them from PyPI source distributions.
- Fixed stale baseline-mode CLI help text.
- Added static LangChain/LangGraph and CrewAI Python adapters with manifest source types, supplemental inventories, framework report blocks, fixtures, and self-check coverage.
- Added framework-specific checks for dynamic LangChain/CrewAI tool surfaces and missing function-tool metadata.
- Promoted GitLab CI and CircleCI to first-class integration recipes with advisory, strict baseline, artifact, multi-config, and tool-source trigger examples.
- Added report schema v0.5 for additive LangChain/CrewAI framework fields.
- Added a framework adapter checklist for future static framework support.
- Deduplicated
source_warnings; baselines from 0.4.x may report a small number of resolved warning entries on first run after upgrade.
- Added declarative YAML policy packs with manifest, CLI, report, SARIF, and GitHub Action support.
- Split
SHIP-API-OPERATIONAL-READINESSinto atomic OpenAI API operational readiness check IDs. - Kept
SHIP-API-OPERATIONAL-READINESSas a deprecated compatibility alias for suppressions, severity overrides, baseline matching, and check metadata. - Removed the legacy top-level
check_severity_overridesalias; usechecks.severity_overrides. - Added report schema v0.4 with
loaded_policy_packsand stabilized Google ADK warnings in the framework surface. - Added an internal framework adapter seam and documented runtime inventory as design-only.
- Added static Google ADK support through
tool_sources[].type: google_adkand supplementalgoogle_adkmanifest artifacts. - Added ADK Python AST and Agent Config YAML extraction for agents, function tools, toolsets, callbacks/plugins, sub-agents, eval references, and explicit local inventories.
- Added six ADK readiness checks covering dynamic toolsets, unfiltered MCP toolsets, missing function metadata, long-running contracts, guardrail evidence, and production eval coverage.
- Added SARIF output via
--format sarifand GitHub Action SARIF/baseline/ADK outputs. - Added report schema v0.3 with a generic
frameworks.google_adksurface summary. - Added reusable local trace normalization for explicit trace/eval artifacts.
- Added manifest-aware checks, deterministic report metadata, check severity overrides,
fail_on,init,doctor,explain, multi-config scan support, and check entry-point hooks. - Renamed the project to Agents Shipgate and hardened v0.1 release-readiness behavior.
- Initial Agents Shipgate MVP.
- Manifest-first scan over local MCP JSON, OpenAPI specs, and optional OpenAI Agents SDK AST metadata.
- Markdown and JSON reports.
- Advisory and strict CI modes.
- GitHub composite action.