Skip to content

refactor(commands): one audience table for common input fields - #2074

Merged
thymikee merged 1 commit into
mainfrom
claude/agent-device-2027-9d286e
Aug 27, 2026
Merged

refactor(commands): one audience table for common input fields#2074
thymikee merged 1 commit into
mainfrom
claude/agent-device-2027-9d286e

Conversation

@thymikee

@thymikee thymikee commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

Closes #2027.

"Who may write this input field, on which surface" was expressed three times, each a separate
name-keyed mechanism: retiredField() in the command field maps, ALWAYS_HIDDEN_FIELDS in the AI
SDK adapter, and OPERATOR_INPUT_GUIDANCE / CONFIG_LOADER_GUIDANCE at the MCP admission boundary —
twelve hand-written refusal sentences keyed by name, far from the fields they govern.

The root cause was that the ~19 shared common fields existed only as parallel enumerations by name
(commonProperties(), readCommonInput(), commonToClientOptions(), and the CommonCommandInput
type), carrying no metadata. Any policy about a field therefore had to be stated in a new
name-keyed map somewhere else.

Now there is one table. src/commands/common-input-fields.ts declares each common field once:

daemonAuthToken: {
  schema: { type: 'string', description: 'Remote daemon auth token.' },
  read: (record) => optionalString(record, 'daemonAuthToken'),
  audience: ENV_OR_OPERATOR_CONFIG,
},

The JSON schema, the readers, the client-options projection, and the model-facing audience boundary
all derive from it, and satisfies Record<keyof CommonCommandInput | 'target', …> makes a row
without a type field — or a type field without a row — a compile error in both directions.

audience is the unified vocabulary (src/commands/input-audience.ts):

audience CLI / Node schema MCP + AI SDK schema supplied as a tool argument
(absent) — the model advertised advertised accepted
operator advertised hidden refused, with the operator path
retired hidden hidden admitted, so the reader answers with migration guidance

retiredField() now sets audience: 'retired'. Metro's bearerToken and proxyBaseUrl declare
audience: 'operator' at the field. stateDir declares it in the new
src/mcp/tool-control-fields.ts, next to the other MCP-only tool arguments, which also stopped being
enumerated three times (advertised schema / stripped-before-dispatch / AI SDK hidden set).

Refusal guidance is rendered from each declaration's operator path rather than written per key —
env var names through the existing buildPrimaryEnvVarName, the operator config file, or an explicit
sentence for the three keys neither describes. Every message is byte-identical to today's.
OperatorInputSource is shaped so a declaration that names no path at all does not typecheck.

What this buys

Reclassifying tenant as operator-only is now this, and nothing else:

   tenant: {
     schema: { type: 'string', description: 'Remote tenant identifier.' },
     read: (record) => optionalString(record, 'tenant'),
+    audience: ENV_OR_OPERATOR_CONFIG,
   },

The schema, the readers, the projection, the MCP admission allowlist, its guidance string, and the AI
SDK schema all follow. In #2023 the operator/endpoint/path removal would have been a handful of row
edits instead of ~250 lines across three mechanisms.

The MCP deny side also became genuinely derived rather than hand-kept: bearerToken is refused on
every tool because the metro command declares it, not because someone remembered to add it to a
map in command-tools.ts. Deleting that one operatorField(...) now turns three existing assertions
red.

Making the wiring unforgettable

A field-level audience only reaches the boundaries if its command's metadata carries it, so two
things close that gap structurally rather than by convention:

  • inputAudience is required on CommandMetadata. A hand-rolled defineCommandMetadata({…})
    that omits it is a type error, not a silently model-writable credential.
  • defineFieldCommandMetadata is the only construction path for a field-map command — it now
    takes an optional custom reader, so batch and gesture (which read their fields themselves) go
    through it too instead of assembling fieldsInputSchema + readFieldInput by hand. click,
    press, and fill were that helper inlined and now call it directly.

At the boundary, a command's own audiences are merged before the global operator classifications,
so an operator key always outranks a colliding per-command retired one — a name collision fails
closed on the refusal instead of open on the migration guidance.

Batch step admission (P1 from review) — superseded by #2076

Review found that admission read only the outer keys of a tool call, so every operator-owned key
refused on the snapshot tool reached the command route by riding in as batch.steps[].input. I
reproduced it and fixed it here — and #2076 landed the same fix on main from a parallel session
while this was in flight.

#2076's design is the one kept, and this branch's version is removed entirely. It declares the
seam on the schema (JsonSchema.commandInputFor names the sibling holding the nested command's
name) rather than on each command's metadata, so the boundary walks the tool's own schema and
re-runs flat admission per nested input. That states the invariant as a parity — a step admits
exactly what the nested command's own tool admits — instead of a key list, and it needs no
per-command declaration to be kept in step.

What this leaves for review here is the #2027 refactor alone, with #2076's
findInadmissibleNestedCommandInput recursing through this branch's audience-derived
findInadmissibleInput. #2076's nested-admission suite passes against it unchanged, which is the
integration proof that the two compose: nested refusals are produced by the same audience map that
produces the flat ones.

Not done, deliberately

The issue also lists COMMON_COMMAND_SUPPORTED_FLAG_KEYS as something to derive from the table.
That is a different axis — which flags the CLI parser accepts — and neither list contains the
other: 25 of its 42 keys (remoteConfig, stateDir, daemonTransport, sessionIsolation,
leaseBackend, sessionLock, every provider* and aws*) never become structured command input,
while the table's cwd and debug are not flags and its deviceTarget row is spelled target
there. Deriving it would mean 25 rows carrying no schema, reader, or projection. The reasoning is
recorded above the constant so the next reader does not have to re-derive it; the table does declare
envFlagKeys, which is the part the issue needed a flag key for.

Validation

No wire or behavior change intended, and that is checked rather than asserted: I serialized
listCommandTools(), every CLI command schema, and every command inputSchema before and after and
diffed them — byte-identical, property order included. All twelve operator and config-loader
refusal messages were captured from the running executor and match the previous strings exactly.

Three independent adversarial reviews ran against the change — behavior parity, security boundary,
and issue fit. Parity found no non-equivalence. The security review confirmed the boundary is sound
and module init order is safe, and raised the merge-order and prototype-key hardening now folded in
above. The design review drove the one-construction-path change, the flag-groups.ts note, and the
test rebalance below.

Local gates green: pnpm check:affected --run, the full unit bundle (1063 files / 8105 tests),
check:layering, check:fallow, check:daemon-wire-compat, check:mcp-metadata,
check:gate-manifest, check:agent-guidance, pnpm build, check:bundle-owner-files,
check:package. check:production-exports reports 83 findings on this branch and the identical 83
on origin/main in a clean worktree — pre-existing, none in the touched files.

One of four full-bundle runs tripped the wall-clock slow-test gate on
gesture-admission-parity.test.ts (5.01s against a 2.5s budget). That test's eager import closure is
82 modules and contains none of the changed or added ones — not even command-input.ts — so this
change cannot affect its import cost; it passes in isolation and the other three bundle runs were
clean. Host variance, per the AGENTS.md rule for timeout-shaped failures.

Every new assertion was observed red before green, against planted defects rather than cited:

  • drop clientKey: 'target' from the deviceTarget row → "deviceTarget is dropped between reader
    and projection"
    ; remove a row's read → the same for that key.
  • delete a row, or add one for a key with no CommonCommandInput field → tsc fails in both
    directions.
  • remove operatorField(...) from metro's bearerToken"metro advertises bearerToken", plus the
    refusal on a non-metro tool losing its guidance.
  • omit inputAudience from a hand-rolled command → "Property 'inputAudience' is missing".
  • declare an operator source with no env var and no config entry → does not typecheck.

The new test file pins the four guidance-rendering shapes and the claim that an operator
classification narrows only the model-facing surfaces — commonProperties() must keep advertising
those keys, or the CLI and Node client lose the operator's own way to pass them, which no MCP test
would catch. Which keys are operator-owned stays pinned by name in
command-tools-operator-inputs.test.ts, at the boundary that enforces it, and that file gains the
complementary assertion that the model-writable common keys are still advertised — so a
misclassification now fails in both directions.

No device evidence: the change is internal to input parsing and schema derivation, and every
observable surface is pinned identical above.

Scope and residuals

26 files, +754/−471 production lines, but move-dominated. command-input.ts was 705 lines against
the 300-line target; its record readers move to src/commands/input-readers.ts so the table can use
them without an import cycle. It is now 476 — under the 500-line hard rule but still over target. The
remaining split (JSON-schema builders vs. field constructors) would separate two things every field
constructor pairs, and cost another eager-closure module, so I left it; the src/cli.ts closure pin
moves 365 → 368 for the three new leaf modules, with the reason recorded in the row.

AGENTS.md gains the new declaration site, and docs/agents/cli-flags.md step 4 now says which flags
have to declare an audience — worth having, since that is the step where a new credential-shaped flag
would otherwise reach a model-facing schema unclassified.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.48 MB 2.48 MB -839 B
JS gzip 834.3 kB 834.4 kB +131 B
npm tarball 957.5 kB 957.7 kB +216 B
npm unpacked 3.32 MB 3.32 MB -839 B

npm unpacked components

Component Base Current Diff
JS / dist source 2.64 MB 2.64 MB -839 B
Apple runner source/project 581.1 kB 581.1 kB 0 B
macOS helper source 54.8 kB 54.8 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 45.3 kB 45.3 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 30.2 ms 30.1 ms -0.2 ms
CLI --help 80.0 ms 80.4 ms +0.4 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/registry.js +787 B +386 B
dist/src/snapshot.js -3 B +13 B
dist/src/android.js -7 B +11 B
dist/src/internal/daemon.js -5 B -5 B
dist/src/runner-client.js 0 B -5 B

Top changed packed files

Packed file Base Current Diff
dist/src/command-tools.js 23.2 kB 21.6 kB -1.6 kB
dist/src/registry.js 155.3 kB 156.0 kB +787 B
dist/src/ai-sdk.js 1.4 kB 1.3 kB -47 B
dist/src/android.js 26.0 kB 26.0 kB -7 B
dist/src/client-metro.js 14.1 kB 14.1 kB +5 B
dist/src/device-input-state.js 4.0 kB 4.0 kB -5 B
dist/src/fill-verification.js 5.9 kB 5.9 kB +5 B
dist/src/internal/daemon.js 110.2 kB 110.2 kB -5 B
dist/src/session2.js 216.4 kB 216.4 kB -5 B
dist/src/snapshot.js 33.1 kB 33.1 kB -3 B

@thymikee

Copy link
Copy Markdown
Member Author

P1: MCP/AI batch bypasses operator-input admission. findInadmissibleInput validates only outer tool keys; batch accepts arbitrary steps[].input, child metadata parses it, command flags project operator-owned paths, and the daemon invokes it. A model can nest iosXctestrunFile (or another operator key) in a batch step. Require shared recursive command-aware admission/schema filtering for batch steps or stop exposing batch, plus planted-red nested operator input. The common table/sizing rationale otherwise looks sound. This is not ready for human review.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head d979f73173421ec75e36679722d607fe919dc34a. Only upstream merges changed it; there is no MCP/batch-admission delta. The previously posted P1 remains unresolved: operator-only input can still be nested in batch.steps[].input past the shallow outer admission boundary. This is not ready.

@thymikee

Copy link
Copy Markdown
Member Author

P1 fixed at 84e7144398. Reproduced first: nested iosXctestrunFile/cwd reached runCommand intact on the previous head.

Admission now recurses, with the nesting declared by the command that nests rather than discovered by the boundary — CommandMetadata.nestedCommandInputs is required and () => [] for every command that nests nothing, so a future command carrying another command's input has to state it instead of silently reopening the hole. Batch resolves a step's command name through the same helper its reader uses, so admission can't admit a step under one spelling the reader then accepts under another; batch isn't batchable, so nesting is one level.

A refused nested key answers with the same guidance as the top-level argument, prefixed by its step, and deny-by-default holds one level down too:

Batch step 2: cwd is not accepted as a tool argument. Start the process serving these tools in
the desired working directory, or pass absolute paths.

Planted red, both directions: reverting admission to the shallow boundary, or dropping batch's nestedCommandInputs declaration, turns the new nested-operator and nested-config-loader tests red. A step whose input is fully advertised still dispatches, so the check doesn't just refuse everything.

No schema change — listCommandTools(), the CLI command schemas, and every command inputSchema are still byte-identical to main, re-diffed after the fix. Only admission behavior moves. Full unit bundle green (1071 files / 8152 tests).

One residual I did not close: nestedCommandInputs is required on the contract, but defineFieldCommandMetadata defaults it to none, so the type forces a hand-rolled command to think about it while a field-map command still gets the safe default silently. Detecting "this schema accepts another command's input" mechanically looked more fragile than useful; happy to add a gate if you'd rather have one.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Correction to my previous comment: #2076 landed the same fix on main while this was in flight, and I've dropped mine in favour of it (9b2a4ab556).

#2076's design is better and is what's kept — declaring the seam on the schema (commandInputFor naming the sibling that holds the nested command's name) means the boundary walks the tool's own schema instead of each command declaring its nesting, and it states the invariant as a parity rather than a key list. Removed from this branch: CommandMetadata.nestedCommandInputs, batch's step extractor and step-command resolver, and my duplicate regression tests. src/core/batch-policy.ts and src/commands/batch/metadata.ts now differ from main only by this PR's own refactor.

The useful part is that the two compose: #2076's findInadmissibleNestedCommandInput recurses through this branch's audience-derived findInadmissibleInput, and its nested-admission suite passes unchanged — so nested refusals come from the same audience map as the flat ones, with no second filter to keep in step.

Surface re-diffed against main at efa8e299ba: listCommandTools(), the CLI command schemas, and every command inputSchema are byte-identical. Full unit bundle green (1072 files / 8155 tests); layering and fallow clean.

My apologies for the duplicated work — I spawned that investigation as a background task before you asked for the fix here, and didn't check for a sibling PR before implementing it myself.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

[P1] Reconcile nested admission with the #2076 contract now on main

The nested MCP/AI admission bypass is closed in this implementation, but the PR's merge CI fails all three nested-admission regressions merged through #2076. Main's contract reports the stable location as batch.steps[i].input, while this branch replaces it with Batch step N, so the merge result is red despite the same security boundary being enforced. Rebase onto current main and keep one owning nested-admission path/diagnostic contract rather than carrying a parallel variant; then make the #2076 regressions and this branch's operator-input coverage agree and rerun Coverage (1).

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

Copy link
Copy Markdown
Member Author

Re-reviewed the replacement head. The #2076 contract conflict is resolved: this branch drops the parallel metadata walker, keeps the stable batch.steps[i].input diagnostic, and delegates nested admission through the nested command's existing advertised-schema boundary using the same structured batch-name resolver as execution. The #2076 parity/later-step/normalization regressions align with the returned path. I found no new code or drift issue. Exact-head type/package, integration, lint, guards, provenance, CodeQL, and macOS smoke are green; coverage and the remaining smoke lanes are still running.

"Who may write this input field, on which surface" was expressed three times,
each a separate name-keyed mechanism: `retiredField()` in the command field
maps, `ALWAYS_HIDDEN_FIELDS` in the AI SDK adapter, and
`OPERATOR_INPUT_GUIDANCE` / `CONFIG_LOADER_GUIDANCE` at the MCP admission
boundary -- twelve hand-written refusal sentences keyed by name, far from the
fields they govern.

The root cause was that the ~19 shared common fields existed only as parallel
enumerations by name -- `commonProperties()`, `readCommonInput()`,
`commonToClientOptions()`, and the `CommonCommandInput` type -- carrying no
metadata, so any policy about a field forced a new name-keyed map elsewhere.

Declare each common field once, in `commands/common-input-fields.ts`, keyed by
its input key and carrying `{ schema?, read?, clientKey?, audience? }`. The JSON
schema, the readers, the client-options projection, and the model-facing
audience boundary all derive from that one table, and `satisfies Record<keyof
CommonCommandInput | 'target', ...>` makes a row without a field, or a field
without a row, a type error in both directions.

`audience` is the unified vocabulary (`commands/input-audience.ts`): `operator`
keys stay in the CLI and Node schemas but are hidden from and refused by every
model-facing tool schema; `retired` keys are absent from every schema yet still
recognized, so they answer with migration guidance. `retiredField()` now sets
`audience: 'retired'`, metro's `bearerToken`/`proxyBaseUrl` declare
`audience: 'operator'` at the field, and `stateDir` declares it in the new
`mcp/tool-control-fields.ts` beside the other MCP-only tool arguments. Refusal
guidance is rendered from each declaration's operator path -- env var names via
`buildPrimaryEnvVarName`, the operator config file, or an explicit sentence --
rather than hand-written per key, and `OperatorInputSource` is shaped so a
declaration naming no path at all does not typecheck.

`#2076`'s nested-step admission recurses through the same derived
`findInadmissibleInput`, so a batch step's refusals come from this audience map
rather than a second filter; its suite passes against this unchanged.

A field-level audience only reaches the boundaries through its command's
metadata, so that wiring is closed structurally rather than by convention:
`inputAudience` is required on `CommandMetadata`, and
`defineFieldCommandMetadata` -- which now takes an optional custom reader, so
`batch` and `gesture` go through it too -- is the only construction path for a
field-map command. At the boundary, a command's own audiences merge before the
global operator classifications, so an `operator` key outranks a colliding
per-command `retired` one and a name collision fails closed.

`command-input.ts` was 705 lines and over the 300-line target; the record
readers move to `commands/input-readers.ts` so the table can use them without an
import cycle. `click`/`press`/`fill` move onto `defineFieldCommandMetadata` --
they were that helper inlined.

`COMMON_COMMAND_SUPPORTED_FLAG_KEYS` stays hand-maintained: it is the CLI
parser's axis, and 25 of its 42 keys never become structured command input while
the table's `cwd` and `debug` are not flags. The reasoning is recorded above the
constant.

Purely internal: `listCommandTools()`, the CLI command schemas, and every
command `inputSchema` are byte-identical, verified by diffing the serialized
surfaces before and after.

Refs #2027
@thymikee
thymikee force-pushed the claude/agent-device-2027-9d286e branch from 9b2a4ab to 738bc1c Compare August 27, 2026 11:16
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 738bc1c after the main reconciliation. The branch-owned audience-table refactor remains clean and now composes with #2076’s nested commandInputFor admission seam and the #2070#2072 platform-boundary changes; it does not restore a parallel metadata walker. The field declaration → metadata/descriptor projection → MCP/AI admission route is coherent, regression tests exercise the owning mappings, and all substantive CI including both coverage shards, integration, smokes, guards, CodeQL, and size is green (only the expected cache matrix skip). Merge-ready.

@thymikee
thymikee merged commit bf26ab1 into main Aug 27, 2026
28 of 30 checks passed
@thymikee
thymikee deleted the claude/agent-device-2027-9d286e branch August 27, 2026 12:03
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-27 12:03 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.

Unify the input-field "audience" model (retiredField / AI SDK hidden / MCP admission)

1 participant