feat: graduation machinery wave D — capability grant/gate/status CLI (ADR-0031 P5/P7) - #157
Merged
Conversation
…only at grant (ADR-0031 §1) grantedCapabilitiesFor now re-derives the whole invariant on every read: a capability is returned only when it is a TIER_GRANTS value AND its gating tier is recorded 'passed' at the exact current hash — so a hand-forged or merged store that just adds a canBePrimary/aqeProvider key is filtered to empty, and aqeProvider is unreturnable even beside a real grant. recordTierGate also drops a grant-bearing capability when it downgrades the backing tier, keeping the stored evidence and the live grant consistent. This makes the invariant a standing property, not just a write-time gate — the prerequisite for wiring grants into the registry safely.
…pability grants (ADR-0031 P5/P7) The maintainer's explicit grant of a tier-earned capability (grant, aliased bless per ADR-0031 §3's 'blessed external adapter'), the upstream-gate recorder (gate <name> <tier> <repo>#NNN), and the per-adapter status view. grant refuses any capability whose gating tier is not recorded passed at the current manifest hash — so canBePrimary/commandStatusline correctly refuse today (their tiers can't yet pass), which is the safety invariant working, and aqeProvider is never grantable (upstream-owned). The grant confirmation discloses the earned evidence, the manifest hook commands, and the consent state before the prompt (resolving the manifest once, so the disclosed trust state describes exactly the bytes being granted), and states plainly that a grant is recorded but currently inert — no runtime consumer reads grants yet. No exercise/callback path is ever reachable from the CLI (a caller result must never be both the pass and its own evidence).
…t-not-hook-bytes evidence boundary (§2)
pacphi
added a commit
that referenced
this pull request
Aug 16, 2026
… run, earn capability tiers, and graduate (#161) * feat: graduation machinery wave A — trust CLI, grant store, remote manifest sources (ADR-0031 P1/P5/P6/P7) (#154) * feat(adapters): hash-pinned capability-grant store (ADR-0031 P5 data layer + P7 gating records) Mirrors the consent store's pin-and-invalidate model for EARNED capabilities: tier evidence (passed/gated) and maintainer grants are recorded against the exact manifest hash; any manifest edit voids the whole record. Grant-bearing tiers (primary-eligible -> canBePrimary, statusline -> commandStatusline) require non-empty evidence; aqeProvider is structurally ungrantable (upstream-owned, ADR-0031 §4). grantedCapabilitiesFor is the only reader capability-wiring code may consume; grantsFor/gatedTiersFor are reporting surfaces with optional staleness annotation. Includes per-tier upstream gating records (gated: <repo>#NNN, format-validated). * feat(adapters): remote manifest sources — npm/https resolvers ahead of hashing (ADR-0031 P6) resolveManifestSource dispatches file / https:// / npm:<pkg>[@Version|tag], wired as admission's default reader so the mandated resolve -> validate -> hash -> consent ordering holds structurally: consent pins RESOLVED bytes, and a mutated remote (moved dist-tag, changed URL content) surfaces as consent-stale, never silent re-trust. Security posture (adversarially reviewed, blockers fixed and re-verified): - https: HTTPS-only, redirects refused outright, abort budget spans headers AND body, Content-Length precheck + streamed hard byte cap - npm: spec charset + name + version/dist-tag validation before any argv (no git/dir resolver smuggling), npm pack --ignore-scripts, tar -xzOf stdout-only extraction — nothing lands on disk, symlink/FIFO/bomb members yield no content instead of a read primitive - file: lstat regular-file gate + size cap (symlinks refused) - every external string entering a SourceError detail is C0/C1-stripped and length-bounded * feat(cli): ak host adapters list/trust/revoke — hash-pinned consent recording (ADR-0031 P1) Closes the consent-required dead end: admission could only ever refuse an external adapter because nothing wrote consent. trust reads, validates, and hashes exactly as admitOne does (validated-shape hash; contract-mismatch / name-mismatch / builtin-shadow refused before any disclosure or write), disclosis the FULL validated manifest (control-char-safe, decision-critical summary + sha256 printed last before the prompt), and records consent via the store seam written for this command. --expect-hash pins a hash and is REQUIRED with --yes on non-file origins (origin defaults fail-closed to unknown); revoke works with the experimental flag off (revocation is fail-safe). list reports trusted / consent-stale / not consented / manifest-error per entry, isolated. * docs(adr): ADR-0031 status table — wave A rows (trust CLI, remote sources working; grant store + gating partial) * test(adapters): inject resolveShimFn so npm-path source tests assert the logical invocation Windows CI failed all 7 npm-path tests: resolveShim rewrites npm/tar into PowerShell shim invocations there, so stubs asserting the pre-shim argv saw the shim shape. The shim rewrite is exec.mjs's own tested responsibility; these tests now inject a passthrough resolver and stay platform-independent. * feat: graduation machinery wave B — external execution (ak run drives an admitted host) (ADR-0031 P2) (#155) * feat(adapters): manifest execution block + hook-runner stdin/cwd (ADR-0031 P2, schema half) Manifest gains an optional execution.run hook, coupled to canRouteActivities (else refused 'execution-not-routable'); it is part of the hashed content. runAdapterHook gains stdin (EPIPE-safe), an absolute-validated cwd, and separate stdoutText/stderrText captures — the substrate the derived execution adapter needs to run a hook without inheriting ak's cwd or promoting stderr into a downstream prompt. * feat(execution): ak run drives an admitted external host as a supervised subprocess (ADR-0031 P2) executionAdapterFor falls through to a manifest-derived subprocess adapter for an admitted host; routing is overlay-aware via a lazy effectiveRoutableHostIds(). No third-party code runs in-process — the hook is one subprocess ak owns. Security-hardened across an adversarial review (blockers + regressions all re-verified closed): - hooks spawn with cwd pinned to the adapter's own resolved directory, never the operator's cwd; a relative hook on a remote (unanchorable) source is refused ('execution-unanchored'). The consent hash still pins the manifest text; resolution is a pure function of it plus the pinned source. - AK_WORKER_CWD carries the target repo without reopening that pin - an unresolved-launch cancellation reports non-escalating 'orphaned', never an escalatable 'timed_out' (no double-run) - the structured payload is parsed from stdout alone, so a stray stderr line never collapses a worker's handoff or leaks logs into a cross-vendor prompt - reserved hook exit codes 77/78 express permission_required/auth_required - a self-declared provider is stamped 'inferred', handoff data is redacted from public results, and driving.surfaces must include cli-subprocess (no downgrade) The black-box conformance test now drives the real command resolver end-to-end: a genuinely spawned subprocess, cwd anchored by production code. * docs(adr): ADR-0029 cli-subprocess execution contract; ADR-0031 external-execution row Working Records the command-resolution policy, reserved exit codes, and the no-trust-laundering rules settled while wiring external execution, plus the honest boundary of the remote-source anchorability screen. * feat: graduation machinery wave C — external lifecycle execution + tiered conformance (ADR-0031 P3/P4) (#156) * feat(lifecycle): external lifecycle execution in setup/sync/uninstall, shape-agnostic (ADR-0031 P3) The lifecycle loops iterate hostsWithLifecycle() (built-ins + admitted) through a shared shape-dispatching renderer (opencode's rich per-surface shape and an admitted host's generic lifecycleResult both handled in one place), and the admission bootstrap now registers an admitted host's derived lifecycle adapter. An admitted host runs only when explicitly enabled in kit.json AND the experimental flag is set; built-in opencode is unchanged. Security-hardened (adversarial review): - admitted lifecycle hooks are cwd-anchored to the adapter's own resolved directory, per-verb 'lifecycle-unanchored' refusal for a relative hook on a remote source (the F-1 arbitrary-code-execution protection, now on the lifecycle path that P3 makes live) - rendered report lines strip C0/C1/DEL and clamp to one line, so a hostile hook cannot forge or erase a report line - the payload is parsed from stdout alone (stderr never collapses a result) - Windows executable extensions are treated as unanchorable bare tokens Also restores each opencode sub-surface's own failure level in the shared renderer (a failed plugin/agents/skill renders as a failure, not green) — fixing a sync-path regression the generalization would otherwise introduce. Known limitation (documented in ADR-0031): the sync path is wired but not yet reachable through a real `ak sync` — status.mjs's subsystem derivation is still opencode-scoped; setup and uninstall are fully live. * feat(conformance): tiered conformance harness + ak host adapters conformance (ADR-0031 P4) runTieredConformance evaluates the five ADR-0031 tiers black-box against an installed layout and records passed/gated results into the grant store against the manifest hash. admission and activity-routing genuinely pass (real subprocess worker through executeRunPlan); session-driving / primary-eligible / statusline report gated/skipped honestly — their runtime paths are not built and the harness never fabricates a pass. Integrity properties (adversarial review): - the harness derives the adapter baseDir identically to production, so it admits the shipped fixture as authored (no test-side command rewriting) - a failed admission tier short-circuits every downstream tier to skipped and persists nothing — no evidence laundered into the grant store - the CLI discloses every hook that will run as a real subprocess before running - all three admitted overlays (host, execution, lifecycle) reset together * fix(execution): treat Windows executable extensions as unanchorable bare tokens Parity with the lifecycle-path anchorability check: a bare hook.bat/.exe/.cmd/ .com/.ps1 with no anchoring base directory would resolve from the operator's cwd on Windows (CreateProcess searches it), the same planted-file vector a relative POSIX script already refuses. * docs(adr): ADR-0031 status — external lifecycle + tiered conformance rows Working (wave C) * test(lifecycle): make external-lifecycle subprocess fixtures cross-platform Windows CI failed the two real-subprocess setup.run_machine tests: the fake globex CLI was a bare extensionless file on PATH, which Windows executable resolution (PATHEXT) never finds, so have() reported it absent and the lifecycle hook was skipped. Mirror the Windows-green withOpencodeCli shim (bare + .cmd + .ps1 pair, which resolveShim requires together). Also move the marker hooks off inline `node -e` — whose JSON-embedded Windows paths CreateProcess argv-quoting can corrupt — onto real anchored script files, and switch the admission F-1 tests' interpreter from bare 'node' to process.execPath. Test-only; production adapters name real binaries. * feat: graduation machinery wave D — capability grant/gate/status CLI (ADR-0031 P5/P7) (#157) * fix(adapters): enforce earned-capability invariant at read time, not only at grant (ADR-0031 §1) grantedCapabilitiesFor now re-derives the whole invariant on every read: a capability is returned only when it is a TIER_GRANTS value AND its gating tier is recorded 'passed' at the exact current hash — so a hand-forged or merged store that just adds a canBePrimary/aqeProvider key is filtered to empty, and aqeProvider is unreturnable even beside a real grant. recordTierGate also drops a grant-bearing capability when it downgrades the backing tier, keeping the stored evidence and the live grant consistent. This makes the invariant a standing property, not just a write-time gate — the prerequisite for wiring grants into the registry safely. * feat(cli): ak host adapters grant/bless, gate, status — maintainer capability grants (ADR-0031 P5/P7) The maintainer's explicit grant of a tier-earned capability (grant, aliased bless per ADR-0031 §3's 'blessed external adapter'), the upstream-gate recorder (gate <name> <tier> <repo>#NNN), and the per-adapter status view. grant refuses any capability whose gating tier is not recorded passed at the current manifest hash — so canBePrimary/commandStatusline correctly refuse today (their tiers can't yet pass), which is the safety invariant working, and aqeProvider is never grantable (upstream-owned). The grant confirmation discloses the earned evidence, the manifest hook commands, and the consent state before the prompt (resolving the manifest once, so the disclosed trust state describes exactly the bytes being granted), and states plainly that a grant is recorded but currently inert — no runtime consumer reads grants yet. No exercise/callback path is ever reachable from the CLI (a caller result must never be both the pass and its own evidence). * docs(adr): ADR-0031 — grant/gate CLI rows Working; record the manifest-not-hook-bytes evidence boundary (§2) * feat: graduation machinery wave D2 — earned capabilities go live (keystone + primary-eligible) (ADR-0031 §1/§2) (#158) * feat(adapters): grant-augmented host overlay — earned capabilities go live in the effective registry (ADR-0031 §1) The keystone that fulfils ADR-0031 §1: at bootstrap the admitted-host overlay reads grantedCapabilitiesFor(name, <freshly-computed current hash>) and raises canBePrimary/commandStatusline on the effective-registry entry, so hostTierLabel and the new effectivePrimaryHostIds() reflect an earned capability with no call-site special-casing. A local allow-list means the overlay can raise ONLY those two caps — never aqeProvider, canRouteActivities, or any other key, even under an adversarial grant map — and can only raise, never lower the manifest floor. A manifest edited since the grant re-hashes, grantedCapabilitiesFor returns {}, and the cap drops: edit-invalidation flows end to end. Flag-off / nothing-granted is byte-identical (effectiveHostRegistry() === HOST_REGISTRY by identity). grantsByName is a null-prototype map read through Object.hasOwn. drivingHost() falls back to a resolvable built-in for an eligible-but- unresolvable host; validatePrimaryHost and PRIMARY_HOSTS stay built-in-only by design (the external-primary selection UX is a later wave) and say so. * feat(conformance): primary-eligible tier genuinely passes via a real escalation exercise (ADR-0031 §2) Replaces the placeholder with a real, unconditional exercise: the harness drives an actual executeRunPlan where the host anchors a run and, separately, receives a genuine ADR-0019 escalation onto itself after an unrouted host's real cli_unavailable failure — both legs run the adapter's own execution.run hook as real subprocesses. Evidence is derived from the real WorkerResult/attempts trail, recorded with NO pre-existing grant, which breaks the earn/grant deadlock the first cut had: passing records evidence, the maintainer's grant turns it into capability (§2), in that order. There is no injection seam — a caller cannot substitute a fabricated pass. primary-eligible short-circuits to skipped unless activity-routing genuinely passed this run; statusline stays honestly gated. * fix(cli): grant disclosure states what a grant actually does now that it is live (ADR-0031) With the keystone landed, a grant is no longer inert: the confirmation and success messages now say it takes effect in the effective host registry from the next ak invocation (tier label + effectivePrimaryHostIds), while honestly bounding the two consumption gaps — no path yet selects an external host as primary, and commandStatusline has no runtime reader — so the maintainer sees exactly which grantable capability does something today. * docs(adr): ADR-0031 — primary-eligible now passes, grants go live in the registry; track N-1 un-earn gap * feat: graduation machinery wave D4 — sync reachability, capability un-earn, F8 gate (ADR-0031 hardening) (#159) * fix(status): admitted lifecycle hosts reach ak sync's convergence plan (ADR-0031 P3 gap) status.mjs's collector now emits a subsystem-tagged row for an enabled admitted lifecycle host (built-in opencode unchanged, byte-identical), so ak sync's subsystem set — derived from the collector's plan — includes it and reaches the admitted-host lifecycle branch that was already wired but unreachable. Gated on the same cfg-enablement + experimental-flag check the sync loop uses; flag-off / nothing-admitted is byte-identical. * feat(adapters): un-earn a capability when its tier re-fails; per-capability revoke-grant (ADR-0031) recordTierFailure voids the stored tier and the live granted capability when a grant-bearing tier re-runs 'failed' at the same manifest hash — the strictly- subtractive mirror of the gated-downgrade, so the earned invariant only ever loses a capability, never gains one. A 'skipped' result never voids (prerequisite not evaluated is not a disproof). ak host adapters revoke-grant <name> [capability] withdraws one capability (leaving tier evidence) or the whole record. Bounded, tracked with the pre-freeze hook-bytes work: the auto-void covers a same-hash failure of the grant-bearing tier itself, not of its prerequisite; a complete fix needs the cli_unavailable-vs-real-failure distinction so a machine merely lacking the host CLI never false-voids a legitimate grant. * fix(lifecycle): a failed detect/plan hook aborts apply, fail-closed (ADR-0031 F8) runLifecycle's apply branch aborts with an honest failed lifecycleResult when detect or plan returns a truthy .error — so an admitted host that signals 'not applicable / refuse' via detect no longer has its apply hook run. The gate fires only on that error shape, which built-in opencode's detect/plan never produce (proven byte-identical); undo/verify/legacy paths are untouched. error is reserved in the detect/plan payload contract. * docs(adr): ADR-0031 — sync now reaches admitted hosts, un-earn path (with its bounds), per-cap revoke * docs: graduation machinery wave E — current-state doc refresh + contributor authoring guide (ADR-0031) (#160) * docs: refresh host-adapter docs to current state + add contributor authoring guide (ADR-0031) PROVIDERS.md and HOST-SUPPORT.md now describe the shipped graduation model — the permanent-caps framing is replaced with earn-then-grant (a manifest can never self-declare canBePrimary/commandStatusline/aqeProvider, but the first two are earnable via a passed conformance tier plus a maintainer grant; aqeProvider stays upstream-owned). Both carry the honest tier table (session-driving/statusline gated, never 'passed'), the precise bounds on what a grant buys today (visible and primary-eligible, but no external-primary selection and no statusline reader yet), and 'contract: 1 is experimental, not frozen'. New docs/AUTHORING-HOST-ADAPTERS.md: the step-by-step contributor guide — write a manifest + subprocess hooks (the only code that runs), publish, self-test with the conformance kit, propose for graduation — with a validating Hermes manifest and a real conformance report, the leverage the machinery provides, and the honest ceilings a contributor can't shortcut. The EXPLAINER's implementer and limitations sections are updated to match (external hosts run, tiers earnable, grants partly consumed) — no stale 'not built' claims. * docs: draft the two upstream capability requests + the contract-freeze checklist (ADR-0031 §4/§6) Drafts, not filed: an agentic-qe provider-plugin request and a ruflo backend-registration request — the honest upstream path for the session-driving and aqe-provider ceilings — each grounded in ADR-0031's cited research and marked to re-verify against upstream HEAD before filing. Plus an evidence-gated freeze checklist: the contract freezes only when a real external adapter (a non-maintainer's, Hermes first) clears the full kit and soaks one release — the honest ceilings that do not block the freeze are listed as such. * docs: complete the EXPLAINER current-state pass — external hosts run today The earlier refresh only touched the implementer and limitations sections; the intro panel, the external-adapters card, and section 5's rollout narrative + diagram still framed external execution as 'the next step'. It shipped in wave B. Now the whole doc reads to current state: an external host is approved (trust), run (ak run supervised subprocess), and certified (tiered conformance); the rollout diagram marks Admit/Trust/Run as shipped and Prove/Open as the earned-not-built remainder; the graduation ladder is described as accepted (ADR-0031), and the capability caps as schema-refused-claims (required false), matching the authoring guide. Session-driving/statusline stay honestly gated; the contract stays experimental until a real adapter clears the kit and soaks.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave D of ADR-0031's graduation machinery, on merged Waves A–C. Behind
AK_EXPERIMENTAL_HOST_ADAPTERS=1.What's in
ak host adapters grant <name> <capability>(aliasbless, per ADR-0031 §3's "blessedexternal adapter") — the maintainer's explicit grant of a tier-earned capability. Refuses any
capability whose gating tier isn't recorded
passedat the current manifest hash, socanBePrimary/commandStatuslinecorrectly refuse today (their tiers can't yet pass) andaqeProvideris never grantable. The confirmation discloses the earned evidence, the manifest hookcommands, and the consent state before the prompt, and states plainly that a grant is currently
inert — no runtime consumer reads grants yet.
ak host adapters gate <name> <tier> <repo>#NNN— records an upstream capability-gate (P7).ak host adapters status [name]— per-adapter passed/gated tiers (stale-marked on a manifestedit) and granted capabilities.
The safety invariant, now standing not just gated
grantedCapabilitiesForre-derives the earned-never-self-declared invariant on every read: acapability is returned only if it's a
TIER_GRANTSvalue and its gating tier is recordedpassedat the exact current hash. A hand-forged/merged store that just adds a capability key is filtered to
empty;
aqeProvideris unreturnable even beside a real grant. This is what makes the upcomingregistry wiring safe by construction.
Quality vetting
Focused adversarial security review of the grant surface (the earned-never-self-declared enforcement
point): the reviewer could not break the invariant via forged stores, tier-status forgery, prototype
keys, wrong-tier evidence, injected
exercisecallbacks, stale hashes, or the alias — verdictSHIP. F-1…F-8 plus a follow-up N-1 (double manifest resolve on the grant confirmation) closed and
re-verified; the F-4 manifest-vs-hook-bytes boundary is documented in ADR-0031 §2 as a knowing
carry-forward with a tightening tracked before freeze.
pnpm run checkgreen (1920 passing).🤖 Generated with Claude Code