Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
989 changes: 989 additions & 0 deletions docs/daemon-modularity-proposal.md

Large diffs are not rendered by default.

33 changes: 25 additions & 8 deletions docs/dependency-graph-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ numbers they explain.

## 0b. The biggest structural finding is not an inversion

> **Current status at `e545544dfa85`:** R9 remains 102, but membership is now commands 33,
> daemon-server 30, platforms 19, core 12, root composition 5, contracts 2, and client 1. None of
> the native replay, replay-test, or Maestro engine files is in this component. A graph
> counterfactual removing the concrete Apple `perf-xctrace.ts` type edge from `daemon/types.ts`
> lowers it to 91 (and daemon membership from 30 to 21). Removing only the Android `perf.ts` edge
> leaves both counts unchanged at 102/30; removing both produces the same 91/21 as the Apple cut
> alone. The Apple edge is load-bearing, while removing the Android edge is ownership hygiene.
> Engine extraction alone leaves R9 at 102.

Cycle size by edge kind, measured over the whole production graph:

| edges considered | largest strongly-connected component |
Expand All @@ -146,12 +155,11 @@ Cycle size by edge kind, measured over the whole production graph:
| all kinds | 213 files |

At runtime the module graph is a clean DAG. The 102-file cluster is purely type-level: you cannot
read the types of any one of those files without transitively reaching all 102. (`main` carries 107;
the boundary moves in this branch bring it to 102.) That is not a correctness
problem — types are erased — but it is a comprehension one, and it is the single largest obstacle to
reading a subsystem in isolation. It spans `commands` (33), `daemon` (21), `platforms` (13), `core`
(12), hubs at `runtime-contract.ts` (25 in-cluster dependents), `commands/runtime-types.ts` (21),
`backend.ts` (15), `commands/runtime-common.ts` (12).
read the types of any one of those files without transitively reaching all 102. (`main` carried 107;
the current boundary moves bring it to 102.) That is not a correctness problem — types are erased —
but it is a comprehension one, and it is the single largest obstacle to reading a subsystem in
isolation. At the current measured commit it spans `commands` (33), `daemon-server` (30),
`platforms` (19), `core` (12), root composition (5), `contracts` (2), and `client` (1).

Now ratcheted for growth by **R9** (`TYPE_CYCLE_BASELINE` in `check.ts`), so it cannot get worse
while nobody is looking — a type-only import that closes a new loop fails the gate, verified by
Expand Down Expand Up @@ -237,6 +245,14 @@ becomes generic over the request type, or the request shape itself moves down.

## 2. `daemon/types.ts` is a second contracts module at rank 4

> **Status after #1435:** the inventory below is historical. `SessionAction`, replay-suite results,
> `DaemonLockPolicy`, and the public daemon response/artifact/runtime-hint shapes now live below
> daemon. Four production files outside daemon still import `daemon/types.ts`; two are
> daemon-specific Maestro adapters scheduled to move back under daemon. `DaemonRequest` itself
> intentionally remains server-private because it carries admitted leases, callbacks, replay
> guards, and narrowed flags. Remove the remaining external imports through neutral caller-specific
> contracts; do not move `DaemonRequest` wholesale.

554 lines, **174 dependents, 173 of them type-only**, and 10 of its 15 exported types are imported
from outside `daemon/`: `DaemonRequest` (8), `SessionAction` (7), `ReplaySuiteResult` (6),
`ReplaySuiteTestResult` (4), `DaemonResponse` (2), plus `SessionRuntimeHints`, `DaemonInvokeFn`,
Expand Down Expand Up @@ -389,8 +405,9 @@ Still duplicated across zones, each needing the same treatment: `fill requires t

## Suggested order from here

1. **Move the 10 outward-facing `daemon/types.ts` types into `contracts/`** (§2). Mechanical, and
it clears most of §1's second cluster.
1. ~~**Move the 10 outward-facing `daemon/types.ts` types into `contracts/`** (§2).~~ Mostly
completed by #1435. Eliminate the four remaining external production importers with
caller-specific public contracts or daemon-owned adapters; keep `DaemonRequest` private.
2. ~~**Split `client/client-types.ts`** (§1).~~ Done — 42 → 18 total inversions. The follow-up is
the upstream moves that unblock the last 5 (§1): `ScrollInputDirection` and the
navigation-projection types out of `commands/`, Metro result payloads out of `metro/`.
Expand Down
165 changes: 165 additions & 0 deletions docs/module-interface-principles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Internal module interface principles

Status: proposed architecture guidance

These rules govern logical modules inside `agent-device`. They optimize for the minimum context and
authority needed to make a safe contribution, not minimum LOC or the maximum number of packages.

## Capabilities narrow authority

> State crosses boundaries as values, authority crosses as capabilities, and both only narrow.
>
> A boundary exists when its port has two implementations; otherwise it is indirection.

Authority travels as an unforgeable argument, never as ambient state:

```text
request token
-> admitted request (lock, lease, provider scope)
-> capability port (one session, no re-locking or session addressing)
-> engine runtime (only the operations its port exposes)
-> platform facet (one request-bound device)
```

An engine cannot mutate another session because its interface cannot express a session name, obtain
a store, acquire another lock, or select another provider scope. The daemon adapter closes over the
already admitted request and supplies only the capability required for that execution.

A port is earned by two real adapters: normally the daemon adapter and a deterministic in-memory
adapter used by the same contract suite. Local-substitutable dependencies such as filesystem writes
use temporary directories directly; they do not acquire a public port solely for mocking.

## Communication rules

Treat these as PR tests:

1. **Down through façades, up through ports, data as values.** Cross-module data is immutable:
prepared plans, step requests, observations, progress events, and typed outcomes. A mutable
object crossing the seam means the seam is fictional.
2. **Every port has two real adapters.** The production and in-memory adapters run the same
contract. If the in-memory twin cannot implement the port without recreating daemon internals,
the port is too broad or sits at the wrong seam.
3. **No event bus between modules.** Control flow remains explicit, call-based, and serialized.
ADR 0018's journal is an observation channel for diagnostics, reporting, and analysis; no module
coordinates state by subscribing to another module's events.

## Session-state consistency

Session state contains three different consistency disciplines. Do not force them through one
generic transaction mechanism.

### Immediate pessimistic transitions

Authorization and observation-lineage transitions commit synchronously before an asynchronous
device operation. Ref-frame expiry is the model: expire immediately before the possible side
effect, and retain that expiry even when dispatch times out or fails.

### Staged protocols

Repair and publication are saga-shaped protocols: arm, establish a watermark, complete, then commit
or abort while preserving operation receipts and failure tombstones. A successful non-idempotent
step commits its operation-keyed receipt before the next fallible step. Retry reuses a matching
receipt; a different operation identity executes again. Their failure handling is domain behavior
owned by the capability, not generic store rollback.

### Append-only streams

Recorded actions and journal events append facts. They are not reconstructed mutable state and do
not coordinate modules.

Collapse each mutable field cluster into one discriminated union so invalid combinations are
unrepresentable. For the script cluster:

```ts
type ScriptPublicationTarget = Readonly<{
path: string;
source: 'generated' | 'explicit' | 'healed-sibling';
force: boolean;
}>;

type RepairPublicationStatus =
| { kind: 'armed' }
| { kind: 'complete' }
| {
kind: 'close-succeeded';
completion: 'armed' | 'complete';
receipt: { operationKey: string };
}
| { kind: 'committed'; path: string; receipt?: { operationKey: string } }
| {
kind: 'aborted';
reason: 'explicit-close-incomplete' | 'lifecycle-incomplete' | 'teardown-commit-failed';
receipt?: { operationKey: string };
};

type ScriptPublicationState =
| { kind: 'inactive' }
| {
kind: 'ordinary';
target: ScriptPublicationTarget;
status:
| { kind: 'armed' }
| { kind: 'published'; path: string }
| { kind: 'aborted'; reason: 'second-open' };
}
| {
kind: 'repair';
boundary: number;
sourcePath: string;
target: ScriptPublicationTarget;
watermark?: ResumeWatermark;
status: RepairPublicationStatus;
};
```

This makes ordinary publication and repair ownership mutually exclusive by construction. It also
keeps the publication target and its force authorization together, and makes a successful platform
close durable before atomic publication. A publication failure remains `close-succeeded`; retrying
the same operation does not repeat the close.

A Redux-like or request-transactional session store is deliberately rejected. ADR 0014 requires
ref-frame expiry to commit in the middle of a request, before awaiting the device operation, and to
survive that operation's failure. End-of-request commit or rollback would recreate the
success-only-rollback bug. Existing mechanisms already provide the useful properties: the request
lock and single-threaded runtime provide isolation, capability modules own mutations, and the event
log provides an audit trail. Actions and reducers would add a shallow indirection layer without
adding safety.

## R7 is the migration ratchet

`SESSION_STATE_FIELD_OWNERS` already assigns every `SessionState` field to its legal writers. Move
one field cluster at a time:

1. take its declared owner and pin the current invariant through the owning interface;
2. move every write behind capability transition methods;
3. collapse the nullable fields into one tagged field;
4. expose immutable `view()` projections to readers;
5. replace the cluster's R7 rows with the single new owner entry.

The shrinking field/owner table is the progress metric. `SessionStore.get()` returning a live record
becomes capability-only; ordinary readers consume snapshots from `view()`.

## Locality is operational

A logical module is one directory containing:

- one façade that presents its complete interface;
- implementation under `internal/`;
- contracts and adapters owned at its seams; and
- tests whose topology mirrors the implementation.

A contributor answering one module question should start at its façade and remain inside that
directory except for named ports and leaf contracts. The gate enforces zero imports into another
module's `internal/` tree. Tests move with their subject rather than remaining in daemon-wide family
files.

Locality is measured by:

- forbidden-import counts at zero;
- public interface size and number of permitted consumers;
- R7 field/owner rows for session state;
- the largest value-plus-type SCC (R9); and
- whether source and tests for one question fit within one bounded directory read.

Moving an engine does not improve R9 when none of its files belong to the SCC. That work continues
at the actual type hubs and concrete platform-state edges after extraction.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
"maestro:conformance": "node --experimental-strip-types --test scripts/maestro-conformance/verify.test.ts scripts/maestro-conformance/differential/run.test.ts scripts/maestro-conformance/differential/invariants.test.ts",
"maestro:conformance:regenerate": "node scripts/maestro-conformance/regenerate.mjs",
"maestro:conformance:differential": "node --experimental-strip-types scripts/maestro-conformance/differential/run.ts",
"prototype:program-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/program-boundary.ts",
"prototype:session-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/session-boundary.ts",
"size": "node scripts/size-report.mjs",
"size:markdown": "node scripts/size-report.mjs --json .tmp/size-report.json --markdown .tmp/size-report.md",
"perf": "node --experimental-strip-types scripts/perf/run.ts",
Expand Down
47 changes: 47 additions & 0 deletions scripts/prototypes/daemon-boundaries/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Daemon boundary prototypes

These are throwaway executable design probes, not production implementations. Their assertions make
two architecture questions concrete before changing module ownership; they are not evidence that
the production migration is complete or behaviorally equivalent.

## Program boundary

Run:

```sh
pnpm prototype:program-boundary
```

Question: can replay-test orchestration run both `.ad` and Maestro files through one small
program-level API without creating a shared replay VM?

The prototype keeps the native replay and Maestro interpreters independent. Each adapter privately
binds its engine to a different runtime port. The test scheduler sees only `inspect` and `execute`;
this is the scheduler seam, not the native engine façade, which the proposal shapes as `prepare`
plus `execute` over one opaque prepared plan. It executes passing and failing examples for both
formats, asserts the independent runtime traces, and prints compact JSON evidence.

## Session aggregate

Run:

```sh
pnpm prototype:session-boundary
```

Question: can a daemon-owned coordinator keep replay from receiving any mutable `SessionState`
interface, while plan-digest validation remains engine-owned and publication remains session-owned?

The prototype uses one internal tagged script aggregate, not an engine port or parallel replay and
publication state. Separate `ReplaySessionTransaction` and `SessionScriptPublication` capability
projections mutate that same aggregate. The repair scenario carries one instance through arm,
corrective-resume watermark, recorded correction, completion, successful platform-close receipt,
failed publication, retry, and commit.

Its assertions pin the transaction edges called out by the production close path: platform-close
failure leaves the whole aggregate unchanged; publication failure retains target/force and an
operation-keyed close receipt; retrying the same close does not dispatch it twice; changing the
close identity does; retargeting without live force drops the old target's authorization; and
terminal committed or aborted state is explicit. The aggregate probe also covers ref expiry before
successful and failed mutations, repair/recording disjointness, digest validation without session
mutation, and single active publication, then prints compact JSON evidence.
Loading
Loading