Skip to content

feat(runtime): add managed-local ownership and the exact-only managed runtime - #2258

Merged
thymikee merged 8 commits into
mainfrom
adr0021/u1-owner-kind
Sep 3, 2026
Merged

feat(runtime): add managed-local ownership and the exact-only managed runtime#2258
thymikee merged 8 commits into
mainfrom
adr0021/u1-owner-kind

Conversation

@thymikee

@thymikee thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

ADR 0021 foundations for managed local runtimes. This branch now intentionally contains the two units that GitHub has already combined: #2258's owner/claim-admission foundation and merged #2259's exact-only managed-owner wrapper plus neutral allocator port. #2263 remains the focused allocator-held claim-store layer on top.

  • RuntimeOwnerRef gains the exhaustive managed-local kind. The existing claim gate maps owners to one total ordinary | allocator-held | none rule, and managed admission returns an explicit admitted/refused result; there is no parallel command path.
  • managedBindingFence is the canonical requester/incarnation fence. Its decoder accepts only the canonical encoding and preserves allocator-issued identifiers verbatim.
  • The existing composed runtime gateway owns one exact-only managedOwners registry. Ordinary selection cannot reach it. The wrapper delegates device mechanics to the existing local-family owner, republishes the binding under the managed owner, and removes lifecycle, implicit-boot, and non-adoptable durable-capture cells through the existing capability/narrowing machinery.
  • @agent-device/contracts/managed-device-allocation is a types-only allocator port with a scripted test fake. It adds no allocator dependency, socket code, or production call site.
  • The wrapper is loaded lazily from the gateway, preserving the eager import closure.

Nothing in production registers a managed owner or calls the allocator yet, so this foundation does not make managed sessions reachable and changes no device-facing behavior. Pre-binding readiness and durable-capture adoption remain explicitly deferred to the allocation-flow/lifecycle units, where they can be exercised end to end.

Combining these units is deliberate now that #2259 was merged into this branch: splitting them again would discard an already-merged unit and create a second implementation path. They share the owner/gateway contract and remain one coherent runtime foundation. Claim persistence and clearing stay separate in #2263 because the claim store is an independent owner with different failure and recovery rules.

Scope: 33 files, +1,943/-180 against main. The npm unpacked increase is 5.6 kB: approximately 2.1 kB is the managed-owner wrapper, 1.1 kB is the batch-runner projection, and the apparent 56.3 kB device-claim-rule.js addition is predominantly the renamed 54.9 kB device-claim-conflict.js chunk. The remaining growth is contracts/projections; startup stayed flat within measurement noise.

Validation

Exact head: 83d5522d3fd4f16afb5de4cb208ef3f8c190476f.

  • GitHub: every required lane is green, including iOS, Android, macOS, Linux, Coverage, Integration, Repo Guards, CodeQL, Agent Guidance, and Size.
  • pnpm check:affected --run: green across format, lint, typecheck, layering, fallow, build, unit, integration, package, and repository guards.
  • Layering: 198/198. Eager-closure budgets: 407/407, with the lazy managed-owner import preventing closure growth.
  • Planted-red coverage exercised the owner-kind switches, total admission decision, canonical fence, exact-only registry, wrapper delegation/capability filtering, composed local transport acceptance, and scripted allocator fake.
  • No simulator/emulator evidence is claimed or required for this foundation: no production path can register or select a managed owner.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.59 MB 2.59 MB +5.6 kB
JS gzip 871.3 kB 873.3 kB +2.0 kB
npm bundled raw 2.59 MB 2.59 MB +5.6 kB
npm bundled gzip 871.3 kB 873.3 kB +2.0 kB
npm tarball 1.02 MB 1.03 MB +1.8 kB
npm unpacked 3.47 MB 3.48 MB +5.6 kB
npm clean-installed 3.47 MB 3.48 MB +5.6 kB

npm unpacked components

Component Base Current Diff
JS / dist source 2.75 MB 2.75 MB +5.6 kB
Apple runner source/project 544.4 kB 544.4 kB 0 B
Apple snapshot presentation source 33.8 kB 33.8 kB 0 B
macOS helper source 54.8 kB 54.8 kB 0 B
Android helper artifacts 43.3 kB 43.3 kB 0 B
Other package files 46.4 kB 46.4 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.9 ms 28.8 ms -0.1 ms
CLI --help 74.3 ms 75.1 ms +0.9 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/device-claim-rule.js +56.3 kB +17.9 kB
dist/src/sdk-batch-runner.js +1.1 kB +283 B
dist/src/session2.js +248 B +118 B
dist/src/internal/daemon.js +108 B +38 B
dist/src/app-inventory-contract.js +5 B -14 B

Top changed packed files

Packed file Base Current Diff
dist/src/device-claim-rule.js 0 B 56.3 kB +56.3 kB
dist/src/device-claim-conflict.js 54.9 kB 0 B -54.9 kB
dist/src/platform-runtime-managed-owner.js 0 B 2.1 kB +2.1 kB
dist/src/sdk-batch-runner.js 82.8 kB 83.9 kB +1.1 kB
dist/src/platform-runtime.js 12.2 kB 12.7 kB +554 B
dist/src/session2.js 217.7 kB 218.0 kB +248 B
dist/src/internal/daemon.js 107.6 kB 107.7 kB +108 B
dist/src/platform-runtime-unavailable.js 3.8 kB 3.8 kB +92 B
dist/src/src8.js 25.4 kB 25.4 kB +55 B
dist/src/perf-runtime-plan.js 12.0 kB 11.9 kB -54 B

@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

BLOCKED at 2c6597837b577af3dc1031ca45806fe04abdf0dc.

providerModeMatchesOwner maps managed-local to mode === local`` only, though the managed owner delegates local platform mechanics and the PR says its managed arm accepts transport-composed. A managed binding with an underlying composed local transport is therefore incorrectly rejected as an owner/facts mismatch. Accept the local-family modes (`local || transport-composed`) and add a binding regression test.

Exact-head iOS Smoke is still pending; other checks, including coverage and integration, are green. No conflict. Do not label ready.

@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Fixed at c17b4b5.

providerModeMatchesOwner's managed-local arm now accepts local || transport-composed, matching local-family. Reasoning: selectExactOwner's managed-local arm loads the device's local family owner through the same loadLocal a local-family owner uses, so it inherits that owner's provider modes verbatim — nothing about the managed wrapper narrows to local only.

The arm is still unreachable from this PR alone (no managedOwners registry exists in U1), so the pinning regression test lives in #2259 where the registry makes it reachable: accepts a managed binding whose local facts report a transport-composed device binds through the gateway with a transport-composed local family device and asserts success. That test replaces an earlier version in #2259 that asserted the opposite outcome — which is what your review caught.

pnpm check:affected --run green at c17b4b5: 532 files / 3879 tests, format/lint/typecheck/layering/fallow/agent-guidance all clean.

@thymikee
thymikee force-pushed the adr0021/u1-owner-kind branch from c17b4b5 to a8ecbd2 Compare September 3, 2026 15:30
@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

CLEAN at this exact head. The rebase is patch-identical to the reviewed six-commit series; the owner-kind/claim-gate design, fail-closed decision, canonical fence, and regressions remain sound. All completed exact-head checks are green. iOS Smoke is still running; once it succeeds, this is first in merge order.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 3, 2026
@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

READY at unchanged exact head a8ecbd2030b28019c06154b8ce784d4105045062. The formerly pending iOS Smoke completed successfully; every exact-head check is now successful or the expected matrix skip. GitHub reports MERGEABLE/CLEAN, and the prior code review remains clean. This is first in stack merge order.

CONTEXT.md sat at 11,992 of its 12,000-byte guidance budget, so no new domain term could be added
without first paying for it.

- Condense eighteen definitions that had grown past one line (platform leaf, command surface,
  runtime use, runner command traits, interactor, coordinate-first resolved element activation,
  parent-owned touch point, guarantee cell, delegation-on-error, ref frame, snapshot producer,
  snapshot policy facet, capture hint, regular presented-depth frontier, clip fold,
  AX-unavailable target invalidation, Maestro program, Maestro observation generation). The
  definitions keep their meaning; only the elaboration is gone.
- Move the five test-harness terms of 'Providers and tests' (provider-backed integration
  scenario, provider transcript, scenario transcript, in-process provider scenario harness, HTTP
  contract test) to docs/agents/domain.md, which AGENTS.md already routes to for domain
  vocabulary. None of them names a concept a command or a wire shape carries, and none appears in
  a test name.

CONTEXT.md is 10,517 bytes after this pass.
…nd managed binding fence

ADR 0021 foundations, unit 1. Nothing registers a managed local owner yet, so every arm below is
reached from tests only; the point of the unit is that the arms exist and fail closed.

- `RuntimeOwnerRef` gains `{ kind: 'managed-local'; instance }` with `managedLocalRuntimeOwner`:
  one owner per allocator instance, family-agnostic because the device carries its family. Every
  owner-kind discrimination becomes an exhaustive switch, so a fourth kind is a type error at each
  site: the owner key, the unavailable-facts provider mode, the durable envelope decode, and the
  gateway's provider-mode acceptance and exact-owner selection.
- `deviceClaimRuleForOwner` ('ordinary' | 'allocator-held' | 'none') in the new leaf
  src/daemon/device-claim-rule.ts replaces the boolean `isLocalDeviceClaimTarget`. Both claim
  gates switch on it, and the admission gate now evaluates it under every device-claim policy: the
  `transient-exclusive` condition moved inside the ordinary arm, so a managed owner is verified
  where an ordinary owner would never have touched the store.
- `requireAllocatorHeldDeviceClaim` (src/daemon/device-claim-allocator.ts) is the one read-only
  verifier both gates consult. It never acquires, never locks and never clears; in this unit it
  can only answer `binding-invalid`, `missing`, or `conflict`, because no allocator-held claim
  kind exists until unit 2. `allocatorHeldAdmissionError` answers each outcome with its own
  refusal through an exhaustive switch, so an outcome the verifier learns to produce is a
  compile error until it is answered.
- A missing allocator-held claim refuses with COMMAND_FAILED / `allocator-claim-missing`,
  `retriable: false`. It is deliberately not a `DeviceClaimConflictReason`: replay retries every
  conflict reason as infrastructure, and a managed identity no allocator activated is permanent.
- `managedBindingFence` / `decodeManagedBindingFence` encode `[requesterId, identityIncarnationId]`
  as the fence token and the request generation as its generation, so two requesters on one
  identity incarnation never share a fence. The ids are fenced verbatim, and the decoder accepts a
  token only if it re-encodes to itself.
- Claim admission now receives the binding intent the gateway bound, so an exact-owner fence
  reaches the gate unchanged. Session open still binds ordinarily and passes an ordinary intent:
  a managed local owner is therefore refused there structurally, and the Host open route replaces
  that intent when it lands.
- CONTEXT.md: managed local owner, device-claim rule, managed binding fence, request generation,
  identity incarnation.
… optional error

`allocatorHeldAdmissionError` returned `AppError | undefined`, so its switch without a default
was never exhaustiveness-checked: TS2366 fires only when the return type excludes `undefined`,
`noImplicitReturns` is off, and oxlint has no exhaustiveness rule. A verifier outcome nobody
answered would therefore fall out as `undefined`, which both gates read as an admission — claim
admission throws nothing and session open proceeds to open the session on a device it never
verified.

Replace it with `decideAllocatorHeldAdmission`, returning
`{ admitted: true } | { admitted: false; error }`. The return type excludes `undefined`, so
dropping an arm is now a compile error at the switch, and a gate asks whether the outcome was
admitted rather than whether an error happened to come back. `buildAllocatorHeldRefusal` and the
admission gate are projections of that one decision.
The condensing pass shortened these five past the point where they still said what they meant:

- Capture hint said 'presented depth' where the term is 'regular presented depth', which is what
  Regular presented-depth frontier is measured against; the short form read as a different axis.
- Clip fold lost both that the interpreter runs inside presentation for every backend and that a
  platform difference may not enter as a backend exception. Those are the whole rule.
- Snapshot policy facet lost the process boundary that makes it host-side at all: runner-side
  Swift presentation stays separate.
- Runner command traits lost 'independently of the public command surface', which is what
  distinguishes them from the command surface.
- Delegation-on-error said 'settles', and Settled observation makes 'settle' a term of its own.

CONTEXT.md is 11,674 of its 12,000-byte budget.
- The claim-gate docstring claimed there is no other way to obtain device operations. That is
  true of command handlers, but two daemon-owned recovery paths bind outside the seam:
  application-lifecycle-recovery.ts (ordinary intent, daemon shutdown) and
  durable-capture-runtime-recovery.ts (exact-owner intent read back from a durable envelope,
  which this unit makes able to carry a managed local owner). Name them instead of claiming
  coverage the seam does not have.
- The open path's comment described a session executing under an allocator-held claim, a state
  this route cannot produce. Say what the `{ kind: 'ordinary' }` literal actually is: the truth
  of a route that binds ordinarily, which the Host open route replaces with the request's exact
  intent when it lands.
- Name U3 as the unit that fills the exact-owner selection arm, rather than the whole ADR.
providerModeMatchesOwner's managed-local arm accepted mode === 'local' only, but
selectExactOwner's managed-local arm loads the device's local family owner through the same
loadLocal a local-family owner uses, so it inherits that owner's provider modes verbatim. A
managed binding over a transport-composed local device (e.g. a remote ADB or web-provider
transport) would fail bindingContractFailure's facts check and be rejected as an owner/facts
mismatch. Accept the same local-family modes the local-family arm already does; still
unreachable until U3 registers the exact-only owner, which is where the binding regression
test that pins this lives.
…per and add the neutral allocator port (#2259)

* feat(runtime): register the managed local owner as an exact-only wrapper and add the neutral allocator port

ADR 0021 foundations, unit 3. Unit 1 added the `managed-local` owner kind and left the gateway's
exact-owner arm for it failing closed; this unit gives that arm a registry and the owner it selects.
Nothing in production registers a managed owner yet, so both are reached from tests only.

- `createComposedPlatformRuntimeGateway` gains a `managedOwners` list that only the `managed-local`
  arm of `selectExactOwner` reads. `selectOrdinaryProvider`, `inspectFacts` and the ordinary `bind`
  arm never see it, and `providerModules` pairs one provider-runtime owner with one
  `ProviderDeviceRuntime`, so ordinary selection cannot reach a managed owner by construction
  rather than by a check. A duplicate instance is refused at composition.

- The wrapper (src/platform-runtime-managed-owner.ts, root zone, no platform imports) binds only
  under an exact-owner intent naming itself, loads the device's own family owner through the
  gateway's loader, delegates with an ordinary intent — a family owner refuses a foreign exact
  owner — and republishes the binding under the managed owner. It does not read the fence: what a
  managed binding fence proves is the device-claim gate's business. `ownsDevice` returns false.

- Twenty cells are withheld as `owner-capability-missing`, enumerated by mechanics rather than by
  catalog group: the four device-lifecycle cells, the four application cells that boot or shut the
  device down (`prepareApplicationOpen`, `prepareAppleRunner`, `closeApplication`,
  `finalizeApplicationClose`), and the twelve durable-capture cells, which a managed binding could
  never reattach because the family runtime stamps envelopes with its own local owner. The
  operations are then filtered by those facts, so an operation cannot outlive its own fact.

- `@agent-device/contracts/managed-device-allocation` is agent-device's own allocator port: lease
  request, lookup, supersession, cancellation, renewal, release, activation confirmation, identity
  status, removal acknowledgement, and the typed environment projection. Types only, named to match
  the allocator's published contract so the two sides cannot drift, with no dependency on any
  allocator package. Its only implementation is a scripted fake under `*.fixtures.ts`.

- Budgets: the new contracts entry surface is a one-module closure; the `src/platform-runtime.ts`
  hub moves 47 -> 48 for the wrapper, whose own value imports were already in that closure.

* fix(runtime): withhold the deployment cells from a managed binding and trim the allocator port

Review findings on the managed local owner.

- `deployApp` and `deployMaterializedApp` join the lifecycle group. Both family deployment runtimes
  ensure device readiness before installing, and `deployAppUse` requires `deployApp` alone — so
  `install` on a managed binding would have booted the allocator's device with nothing to refuse
  it. Twenty withheld cells become twenty-two, and the refused-uses test covers `deployAppUse`.

- The wrapper's doc comment no longer implies that withholding cells is a complete lifecycle
  exclusion: several retained Apple cells (screenshot capture, settings, clipboard, application
  launch) boot the simulator lazily inside the family runtime, where cell selection cannot reach.
  That is the same class as the pre-binding readiness path, and closing it is a family-runtime
  change.

- `readLeaseEnvironment` leaves the allocator port. It was beyond the vocabulary the contract
  fixes, and it made the scripted fake carry a real parser whose only test passed with every
  production line reverted. `ManagedLeaseEnvironment`, `ManagedLeaseEnvironmentKey` and
  `LeaseEnvironmentError` stay as types; the reader that produces them lands with the unit that
  first turns a grant into a device.

- CONTEXT.md drops an operation enumeration that was already incomplete.

* fix(runtime): withhold the lazily-booting Apple system and screenshot cells

Screenshot capture, settings, clipboard and application launch were retained on a managed
binding even though their Apple family-runtime implementations can boot the simulator lazily
below cell-selection granularity (screenshot's shutdown-failure retry boot; settings, clipboard
and application launch each resolve a local interactor the same way). That preserves rather than
blocks the exact bypass ADR-0021 section 3's hard boundary names: managed lifecycle/readiness
belongs to the allocator, and no handler path may fall back to direct lifecycle tooling.

Withhold captureScreenshot, setSetting, readClipboard, writeClipboard and openApplication
alongside the existing withheld groups. The wrapper's doc comment now names the pre-binding
readiness gap explicitly as the same class of follow-up, rather than folding it into a retained-
cells caveat that no longer applies. MANAGED_RETAINED_OPERATION moves to tapPoint, the cell the
fixture-based regression tests now use to prove something survives the wrapper.

* chore: retrigger CI (stale synchronize event after rebase)
…r-closure no-growth gate

Main's eager-closure budget gate (the merge-base ratchet) replaced the hand-tracked
HUB_BUDGETS map with an automatic no-growth-vs-merge-base check: src/platform-runtime.ts
is a hub with no growth allowed at all, not a number bumped by hand with a justifying
comment. The static import of createManagedLocalRuntimeOwner in platform-runtime-gateway.ts
added one module to that hub's closure (47 -> 48), which now fails
scripts/__tests__/eager-closure-budgets.test.ts outright rather than needing a manual bump.

Move the value import into loadManaged's dynamic `await import`, matching how the rest of
this file's owner loaders defer their leaf modules. Only the managed-local arm reaches this
path, so an ordinary bind never pays for it, same as before -- the wrapper module itself was
simply the wrong side of the eager/lazy line.
@thymikee
thymikee force-pushed the adr0021/u1-owner-kind branch from b8f5fad to 83d5522 Compare September 3, 2026 16:16
@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 3, 2026
@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

BLOCKED at 83d5522d3fd4f16afb5de4cb208ef3f8c190476f. The lazy managed-owner import is sound and the eager-closure gate passes, but #2259’s merged U3 squash is now inside this branch. This PR is therefore 33 files/+1943/-180 while its title/body still describe U1 only, call U3 a future sibling, say no production managed owner can bind, and report obsolete scope counts. Restore the U1-only split or explicitly rescope title/body/dependencies/validation and justify combining U1+U3. iOS Smoke is also still running. ready-for-human is being removed until scope and evidence match the head.

@thymikee thymikee changed the title feat(runtime): add the managed-local owner kind, device-claim rule, and managed binding fence feat(runtime): add managed-local ownership and the exact-only managed runtime Sep 3, 2026
@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Scope blocker resolved at exact head 83d5522d3fd4f16afb5de4cb208ef3f8c190476f.

#2258 now explicitly owns the combined U1+U3 foundation created when #2259 merged into this branch. The title/body, dependency order, 33-file scope, +1,943/-180 accounting, +5.6 kB size evidence, lazy-import rationale, deferred production boundaries, and exact-head validation all match the code. Preserving the merged unit avoids orphaning it or recreating a second implementation path; #2263 remains the independent claim-store layer.

All exact-head checks are green and GitHub reports CLEAN.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 3, 2026
@thymikee
thymikee merged commit e882cf9 into main Sep 3, 2026
19 of 20 checks passed
@thymikee
thymikee deleted the adr0021/u1-owner-kind branch September 3, 2026 17:41
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-03 17:42 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant