Skip to content

feat: graduation machinery wave D — capability grant/gate/status CLI (ADR-0031 P5/P7) - #157

Merged
pacphi merged 3 commits into
developfrom
feat/graduation-wave-d
Aug 16, 2026
Merged

feat: graduation machinery wave D — capability grant/gate/status CLI (ADR-0031 P5/P7)#157
pacphi merged 3 commits into
developfrom
feat/graduation-wave-d

Conversation

@pacphi

@pacphi pacphi commented Aug 16, 2026

Copy link
Copy Markdown
Owner

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> (alias bless, per ADR-0031 §3's "blessed
    external adapter") — the maintainer's explicit grant of a tier-earned capability. Refuses any
    capability whose gating tier isn't recorded passed at the current manifest hash, so
    canBePrimary/commandStatusline correctly refuse today (their tiers can't yet pass) and
    aqeProvider is never grantable. The confirmation discloses the earned evidence, the manifest hook
    commands, 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 manifest
    edit) and granted capabilities.

The safety invariant, now standing not just gated

grantedCapabilitiesFor re-derives the earned-never-self-declared invariant on every read: a
capability is returned only if it's a TIER_GRANTS value and its gating tier is recorded passed
at the exact current hash. A hand-forged/merged store that just adds a capability key is filtered to
empty; aqeProvider is unreturnable even beside a real grant. This is what makes the upcoming
registry 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 exercise callbacks, stale hashes, or the alias — verdict
SHIP. 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 check green (1920 passing).

🤖 Generated with Claude Code

pacphi added 3 commits August 16, 2026 11:43
…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).
@pacphi
pacphi merged commit 6248196 into develop Aug 16, 2026
14 checks passed
@pacphi
pacphi deleted the feat/graduation-wave-d branch August 16, 2026 18:46
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant