This file is the single source of truth for cross-process wire contracts. Both bridge mirrors (TS host + Python brain) build against THIS doc. Changing a shape here is a deliberate, versioned act — not a side effect of a code edit. If code and this doc disagree, this doc wins and the code is the bug.
The event seam between the headless brain (decides) and the TS host (renders +
executes). Full prose + rationale: BRIDGE_PROTOCOL.md.
Mirrors that MUST stay in lockstep with this version:
aether_agent/protocol.py(Unlimited-Context) —PROTOCOL_VERSIONsrc/core/brain_protocol.ts(this repo) —PROTOCOL_VERSION
Conformance fixture (the drift detector): an identical
bridge_conformance.json lives in test/fixtures/ (this repo) and
tests/fixtures/ (Unlimited-Context). Each side's test suite loads its copy and
asserts its codec round-trips every message + that protocol_version matches the
constant. A failing conformance test = drift; fix the code or bump the version.
PROTOCOL_VERSION is the MAJOR (breaking) integer. Bump it (here + both
mirrors + both fixtures) only on a BREAKING change:
- removing/renaming a message
typeor field, - changing a field's type or wire key, or making an optional field required.
Additive, forward-compatible changes do NOT bump the integer — a new message
type, or a new OPTIONAL field on an existing message, that old (v1) consumers
safely IGNORE. This is deliberate: downstream consumers gate on the integer, and
a bump falsely signals "breaking" to them. Additive changes are
instead recorded here + mirrored in both codecs + added to the fixture so the
conformance test covers them. (Receivers MUST already ignore unknown types.)
History:
- v2 —
turnevent +done.remaining/done.reason+task.test_cmd(the loop-fix / final-verification-gate patch). All additions are backward-tolerant (old consumers ignore the new event + optional fields); the integer was bumped to 2 alongside the schema rev so the conformance fixture stays in lockstep across both repos. - v3 — the
web_search/web_fetchtools joinedTOOLS(see Invariant 2). - v4 — bounded
list_directoryand digest-checkedpatch_filejoinedTOOLS;read_filereturns bounded content, range cursors, and a SHA-256 digest. Separately, and never recorded here until now: the workflow swarm frames —workflow_start,phase_start,phase_done,agent_spawn,agent_progress,agent_done,workflow_done(the CODEPRO/HIGH+-effort multi-agent workflow view) — also landed inbrain_protocol.tsduring the v2->v3 window. Per the versioning rule above they're purely additive and didn't need their own bump; this doc's silence on them until now was a drift, not a deliberate omission, closed by this change per this doc's own rule ("if code and this doc disagree, this doc wins and the code is the bug"). Also additive, layered on top of the now-documented v3 baseline:agent_donegained optionaltokens/tool_calls/duration_ms(Tier-2/3 per-agent metrics for the terminal/desktop workflow panels —docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md); absent on the wire, these decode toundefined, never a fabricated0. - Known gap:
test/fixtures/bridge_conformance.json(this repo) does not yet include any of the v3 workflow-swarm frames in itseventsarray, so the conformance/drift-detector tests below don't exercise them. Extending the fixture requires an identical update to the Unlimited-Context mirror's copy (tests/fixtures/bridge_conformance.json, a separate repo) to avoid creating the exact cross-repo drift this fixture exists to prevent — flagged here, deliberately left for a coordinated cross-repo change rather than fixed unilaterally in this PR.
brain → host (events)
| type | fields | meaning |
|---|---|---|
stage |
name, face |
staged-lifecycle marker |
monologue |
text, depth |
nested reasoning-tree line |
skill |
name, reason |
a procedure packet was pinned |
turn |
n, tool_calls, malformed, invented, no_call, fail_count |
per-assistant-turn diag (§8 emission curve) |
tool_call |
id, name, args |
host must execute + reply with tool_result (same id) |
telemetry |
tokens, tps, ctx_used, ctx_cap, vram |
live effort/velocity |
status |
phase, pool_used, pool_cap |
drives the pool bar (pool_cap = pool_gb × 233M) |
checkpoint |
git_sha |
a verified step was committed |
done |
ok, result, remaining, reason |
run finished; ok from a real final test run (see invariant 5) |
error |
msg |
run aborted |
workflow_start |
workflow_id, phases[{n, type, agents}], total_agents |
a CODEPRO/HIGH+ multi-agent workflow began |
phase_start |
phase_n, phase_type, agent_count |
a workflow phase began |
phase_done |
phase_n, artifact_summary |
a workflow phase completed |
agent_spawn |
agent_id, phase_n, brief |
one swarm agent started |
agent_progress |
agent_id, delta |
streamed output from one swarm agent (emitter not confirmed on the shared backend — see docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md Finding E) |
agent_done |
agent_id, phase_n, summary, tokens?, tool_calls?, duration_ms? |
one swarm agent finished; the last 3 fields are optional Tier-2/3 metrics, absent on older brains |
workflow_done |
synthesis, total_phases, total_agents |
the multi-agent workflow finished |
host → brain (commands)
| type | fields | meaning |
|---|---|---|
task |
text, cwd, pool_gb, effort, model, test_cmd |
starts a run (first message). test_cmd="" → unverifiable run |
tool_result |
id, output, exit_code |
reply to a tool_call (id MUST echo) |
control |
action (pause|resume|steer), note |
interactive control |
- Tool-call correlation. The brain emits ONE
tool_calland blocks until the host replies, so replies are strictly ordered. Atool_resultwhoseiddoes not match the outstanding call is a protocol violation → the brain emitserrorand aborts (it does NOT skip — skipping mis-pairs results to calls). - One tool implementation, host-side.
read_file · list_directory · patch_file · write_file · run_shell · run_tests · repo_search · git_commit · web_search · web_fetch(the full canonicalTOOLSset,src/core/brain_protocol.ts; this row previously listed only the first 6 — pre-existing drift, closed by this change). A single path-guard canonicalizes (realpath: resolves.., absolute paths, and symlinks) BEFORE the workspace allowlist check for the filesystem/shell tools;web_search/web_fetchhave no repo path to canonicalize and are guarded separately (SSRF/loopback/redirect checks,src/core/web.ts). Output is[exit N]\n…, capped, with stderr captured. - Encoding is lossless, codec-boundary only. The wire is ASCII-escaped
(
ensure_ascii) so it survives a Windows cp1252 pipe; decode restores exact UTF-8. Rendered frames are real UTF-8 — escaping never touches them. - Cloud parity (honest gap). Today's cloud SSE runs tools server-side and
emits no
tool_callframe, soCloudBrain.sendToolResultis a no-op. When the server addstool_callframes + an upstream channel it implements the same round-trip — no host change. This is a known divergence, not silent. done.okis ground-truth, never self-report. The brain runs the test command one final time beforedoneand derivesokfrom its exit code — an agent must never emitok:truethat contradicts its own lastrun_tests. A no-tool-call turn means "verify, then keep going or stall", NOT "success".done.reason∈ {"", stalled, no-progress, max-turns, unverified}; the host manifest'sfinalStatusmirrors it (okonly on a verified green;unverifiedwhentask.test_cmd="").remaining= failing tests when not ok.
read_file reads files up to 16 MiB into one bounded buffer. UTF-8 validation,
SHA-256, and returned byte or line ranges use that same buffer. Its opaque
revision binds opened-handle identity and metadata to the content digest;
timestamps alone cannot identify same-tick rewrites.
On Linux, an expected_revision continuation recomputes this bounded snapshot
and refuses a different revision with stale_revision. Successful bounded
reads return the actual sha256 and validation_scope: "whole_file", including
guarded continuations. Guarded reads on other platforms or above 16 MiB return
revision_unsupported before content I/O. Larger files retain unguarded bounded
byte paging with sha256: null and validation_scope: "returned_range".
Opened-handle containment, symlink checks, observed read-conflict checks, and
the output budget still apply.
write_file creates new paths exclusively. Existing-file replacement requires
both expected_revision and the executor-scoped replace_token emitted only
by a complete byte-mode read of that path. Partial reads cannot supply the
token. The host checks the same digest-backed revision over at most 4096 bytes
after staging the replacement; larger files use patch_file. The old file is
never opened with truncation. The final same-directory rename is atomic where
supported, and a rename failure preserves the old file. The check and rename
are separate filesystem operations, so non-cooperating external writers need
workspace locking for strict serializability.
Host-executed loops — hostLoop (aether agent) and runLocalTurn (local
chat) — bound repeated failed tool calls in src/core/tool_failure_budget.ts.
Hosted server-side tools, the headless aether exec loop and the smoke loop
are out of scope here and need their own contract. Web tools report failures
with exit 0, so they are never counted.
- Classified. Every non-success result is one of
invalid_arguments,permission_refused,unavailable,stale_precondition,transient,unknown_outcome,execution_failure. A cancellation ([aborted…], 130) is not counted; it ends the turn through its own path. Only the executor's own[timeout after …]is an unknown outcome; a command's own[exit 124]is an ordinary execution failure. - Fingerprinted. The canonical operation is a SHA-256 of the host's approval binding (tool + validated arguments, key order irrelevant), of the stable JSON of the raw arguments when validation failed, or of the tool name alone for a skill-policy refusal. Call ids never make a repeat look new.
- Host decisions are not state-bound. A policy refusal, an argument rejection or an operator denial is bound to the operation alone: an unrelated edit never puts a denied action in front of the operator again.
- Tool failures are state-bound. A failure the tool produced counts only
against unchanged relevant state: the git working-tree revision (HEAD +
changed paths + their stat; probed with
--no-optional-locks,core.fsmonitor=false, onestatus --porcelain=v2 --branchprocess, a 3 s bound) for shell/git tools, the target file's stat for path-bearing tools, the shell context, the configured test command, and an epoch advanced by every successful write/patch/git_commit and every successfulrun_shellthat is not a plain read-only command (ls,cat,git diff, … — no chaining, pipes or redirection). Each attempt's pre-execution state is compared with the previous attempt's post-execution state, so a failing command that rewrites files itself is still a repeat. A probe that errors or times out reads as changed (only "not a git repository" reads as the constantno-git). Git is probed only for a failed shell/git call or a repeat of one — never for a success or a host decision. - Budget, per class (failed attempts allowed):
permission_refused1 ·unavailable1 ·invalid_arguments2 ·stale_precondition2 ·unknown_outcome2 ·transient3 ·execution_failure3. Independently, 8 consecutive failed or refused calls with no success stop the cycle; from the 6th the failure result carries a[host: N tool calls in a row have failed …]warning. - Refuse, then stop — before acting. The first request after the budget is
spent is REFUSED: answered under its own call id with
[host refused repeat: … To recover: …], never executed, never prompted. This is the model's warning. A request after that refusal STOPS the cycle — but only from a LATER model reply: brains that know their reply boundaries (Brain.modelRound(), implemented by the Ollama brain) keep answering identical calls batched into the same reply with the refusal, and the streak bound likewise waits for the next reply, so a batch can never end a run before the model has seen a single result. A brain without rounds treats every call as its own reply. A decided refusal (policy/denial) is warned again rather than stopped when real work (an edit or state-changing shell run) happened since the last refusal. The stop is answered with[host stopped repeated failure: …], the brain is closed, and the host emitsdone{ok:false, reason:"no-progress"}through the normal event path. For the in-process Ollama brain the close lands before any further model request; for remote and child-process brains, delivery of that last result is best-effort because the same stop tears their session down. - Settled outcome. The coding turn settles
incomplete. The preserved work is still checked by the host, but a green check cannot turn a stop into a success, and the session is filed asno-progress.--jsonadds acheckpointobject. A local chat turn ends with the same message and hint. - Checkpoint. Operation, class + reason, attempt count, preserved work (successful calls; edits and commits kept, with the files written; shell commands run; nothing is rolled back), and one recovery choice. A permission refusal's recovery is "approve when prompted, or work without it". The path guard's is "stay inside the workspace". It never names a broader mode or a substitute action.
- Never. Successful calls are not deduplicated; a success clears the
fingerprint. A repeated failure carries a short
[host: …]note; the first is delivered byte-for-byte. The host retries only a transient failure of a read-only tool, once, within the same approval and deadlines. A mutation whose outcome is unknown is never replayed. Turn, time, output and progress caps are unchanged.
The cross-repository shapes for local broker connectors, account onboarding and
the approval bridge. Mirrors: src/core/ats_contracts/ (this repo, TypeScript)
and the ATSv2 Python connector core. Source spec: Aether Agent trading
integrations finale — Spec 1, sections 11.1–11.8.
Conformance fixture (the drift detector): test/fixtures/ats_contracts_golden.json
holds one validated example per schema plus its canonical digest. The matching
ATSv2 fixture and independent Python digest checks are proposed in ATSv2
#443. Its Python checks do not
yet reproduce all eight TypeScript validators or translate ATSv2's native
agent_bridge/v1 objects. A matching digest proves canonical bytes agree for
these examples; it does not prove that ATS accepted an order or a grant.
aether.ats.model-order-proposal/1 is the closed untrusted model input.
Its separate fixture is test/fixtures/ats_model_proposal_golden.json. It
contains strategy and quote references, symbol, side, whole-share quantity,
order type, price and expiry. It has no account, environment, grant, client,
request ID or operator decision. ATSv2 must inject all of those after client
authentication and revalidate every reference. The equity-order-intent/1
below is an ATS-private bound object; it must never be exposed as a model tool
argument. Neither proposal validation nor its digest is execution authority.
| Tag | Shape | Spec |
|---|---|---|
aether.ats.execution-state/1 |
requested-vs-effective mode | 1 §11.8, 2 §7.2 |
aether.ats.connector-capability/1 |
BrokerConnectorCapabilityV1 |
1 §11.1 |
aether.ats.account-binding/1 |
BrokerAccountBindingV1 |
1 §11.2 |
aether.ats.delegated-trading-grant/1 |
DelegatedTradingGrantV1 |
1 §11.3 |
aether.ats.equity-order-intent/1 |
NormalizedEquityOrderIntentV1 |
1 §11.4 |
aether.ats.order-review-receipt/1 |
BrokerOrderReviewReceiptV1 |
1 §11.5 |
aether.ats.operator-approval/1 |
OperatorApprovalReceiptV1 |
1 §11.6 |
aether.ats.execution-receipt/1 |
ExecutionReceiptV1 |
1 §11.7 |
Frozen weakness, never executable. The /1 ticker check (symbol())
accepts URL-shaped strings such as HTTPS://X, and the /1 masked-label check
accepts other-script digits, confusable letters, direction overrides and
zero-width characters. Both stay exactly as frozen so historical records keep
validating. No /1 document or chain may authorize an executable order; that
takes the /2 shapes and verifyExecutableCommitAuthority() in "Spec 1 /2
closure" below.
A real RFC 8785 (JCS) implementation, in src/core/ats_contracts/canonical.ts.
It is not shared with device_runtime/canonical_json.ts, and must not be.
That encoder is pinned to the Cloud's Python json.dumps(..., ensure_ascii=True)
and changing it would invalidate device signatures. Two encoders exist here
deliberately, each pinned to a different counterpart.
An earlier revision of this section described a profile called
jcs-integer-subset/1 and claimed it was 8785 minus floats. That was wrong.
It escaped every non-ASCII character (ensure_ascii=True), while JCS requires
non-control Unicode emitted literally and encoded as UTF-8 — a different byte
string, and therefore a different digest, for any document containing a
non-ASCII character. The name implied a narrowing when the difference was the
string encoding.
What the profile actually guarantees:
- Non-control Unicode is emitted literally; only
"\\b\f\n\r\tand lowercase\u00xxfor remaining C0 controls are escaped. - Object keys sort by UTF-16 code unit (§3.2.3).
- Numbers use ECMAScript Number-to-String;
-0normalizes to0. - Lone surrogates are refused — they have no UTF-8 encoding, so runtimes substitute or throw differently and the digest stops being reproducible.
Floats are legal in JCS and this encoder accepts them. Integer-only money is
an ATS contract rule, enforced one layer up by the schema validators
(integer(), minorUnits()): every monetary field is an integer count of minor
units (58012 = $580.12) and every quantity is a whole share. Keeping the split
means the encoder stays a faithful 8785 implementation instead of quietly being
something else again.
sorted(keys) is wrong. Python compares code points; RFC 8785 requires
UTF-16 code units. They disagree whenever an astral character (U+10000+) meets a
BMP character at or above U+E000, because the astral character's UTF-16 form
begins with high surrogate 0xD800, which is numerically below U+FFFF:
UTF-16 (correct): {"\U00010000":1,"�":2}
sorted() (wrong): {"�":2,"\U00010000":1}
Sort on key.encode("utf-16-be", errors="surrogatepass"), and serialize with
ensure_ascii=False. test/fixtures/ats_contracts_golden_verify.py is a
complete reference implementation plus a fixture checker; ATSv2 should lift its
functions. It is not wired into npm test because CI has no guaranteed Python —
a conditional skip would give false assurance. Verified at freeze time:
OK: 18 checks reproduced byte-for-byte by an independent Python implementation.
Both mirrors assert the profile string, so changing the encoder is a deliberate, versioned act rather than a silent digest change.
- Implementation support is not permission. A capability snapshot pins
grants_execution_authority: false; any other value fails to parse. - Effective mode never exceeds requested mode, on the ladder
offline < observe < review_only < paper < approve < auto. The halt statesorders_paused/emergency_lockedmay follow any request. A differing effective mode must carry a reason. - An empty symbol allowlist permits nothing. There is no wildcard token.
confirmationis pinned toper_order. Standing or session approval is not expressible, even though the provider may offer it.- A bare account number cannot be passed where a reference belongs. Opaque
refs require a namespace prefix (
acct_…); a masked label with 5+ consecutive digits is refused;redactBindingForExport()is the only exported projection. Scope limit, stated plainly: a prefix cannot prove an account number is absent from the body —acct_000123456789satisfies the shape. Non-reversibility must be produced upstream, by the connector core minting these as random or keyed-digest identifiers. The validator cannot see the difference, so that half of the invariant belongs to whoever generates them. - Preview, approval and commit must name the same provider, account binding
id, binding generation, adapter, endpoint/schema digest and execution
environment.
verifyApprovalChain()computes both digests itself from the intent and review — an earlier revision accepted the intent digest as an argument, which proved only that three documents agreed about a number the caller supplied. It also refuses a refused or expired review and a spent or expired approval. There is deliberately no re-preview or reroute path; a mismatch is terminal.verifyCommitAuthority()is the gate immediately before a broker commit: it additionally proves the order is aimed at the account the local binding names, under a grant that still permits it, in a mode that still allows submission. Kill and pause are re-checked there, not inherited from whatever the review said minutes earlier. - Fill facts exist only when broker-confirmed. A receipt cannot express a fill for a refused, cancelled or ambiguous outcome, nor fill more than it ordered.
- An ambiguous commit is never retried.
ambiguousis a first-class outcome requiring a reconciliation state and a reason.
Gate 1.0 lands no connector write path. src/core/ats_contracts/ performs no
I/O, holds no credential and places no order — a test asserts the modules
reference no node:fs / node:net / node:http / node:child_process /
fetch( / process.env. Broker sessions, the operator gateway and the
submission coordinator arrive in later gates and consume these shapes.
Related Spec 2 contracts (runtime, data profile, strategy lifecycle, journal) land in a separate lane under the same directory and import this base.
Spec 2's half of the freeze, in src/core/ats_contracts/. These import the
section 2 base (primitives.ts, canonical.ts, mode.ts) rather than
redefining it, so both specs share one canonicalization profile
(rfc8785/1) and one authority ladder.
| Schema | Module | What it asserts |
|---|---|---|
aether.ats.runtime-installation/1 |
runtime.ts |
What was installed, and that its provenance was proven |
aether.ats.runtime-capabilities/1 |
runtime.ts |
What the runtime honours right now |
aether.ats.data-profile/1 |
data.ts |
What data was configured |
aether.ats.data-probe/1 |
data.ts |
What a live probe actually saw |
aether.ats.strategy-source/1 |
strategy.ts |
An imported strategy file |
aether.ats.strategy-compile-artifact/1 |
strategy.ts |
An immutable, content-addressed compile result |
aether.ats.strategy-activation/1 |
strategy.ts |
What the runtime was asked to run, and what it honoured |
aether.ats.trade-journal/2 |
journal.ts |
A trade entry: immutable facts plus editable human context |
aether.ats.human-note-revision/1 |
journal.ts |
One appended revision of a human note |
aether.ats.journal-preferences/1 |
journal.ts |
Journal preferences, which grant no authority |
Invariants these shapes make unrepresentable rather than merely forbidden:
- A receipt cannot lie about provenance.
provenance_verifiedis pinnedtrue, so a failed verification produces no installation receipt at all. - Configured is not verified.
DataProfileV1has no field that can hold a connection verdict, so settings cannot persistconnected: true. - A verified probe expires.
ageProbeReceiptre-evaluates against the clock, so a previous success is not perpetual evidence and a stale feed blocks a new activation. - A credential reference is not a credential.
credential_refrequires anenv:orvault:prefix, so a pasted key fails validation at the boundary. - Compile is not activation. Only a compiled, content-addressed artifact whose id matches its contents can be staged; Pine and Python are reference material and never compile to Nano.
- Paper needs a grant. An activation effective in
paperwithout an execution-grant reference is refused;observemay not carry one at all. - Notes append. A note revision must supersede exactly its predecessor, a
save from a stale base returns a conflict, and editing a reflection advances
only
reflection_revision— neverfact_revision. - Effective mode is ATSv2's. The only way to obtain a non-offline capability snapshot is to parse a runtime-authored reply; there is no constructor that derives one from a local preference.
Golden vectors: test/fixtures/ats_spec2_golden.json, 15 frozen documents with
their canonical digests, a sibling to section 2's fixture. Changing a digest
there is a deliberate, versioned act.
Runtime state lives in runtime.json, data-profile.json, dashboard.json
and setup.json beside the immutable ats.json (aether.ats.local/2), so an
older Agent build cannot misread runtime authority as setup state.
The typed wire between ATSv2's browser order-ticket port (caller) and the
qualified site adapter in the private extension on the browser host (callee).
Source spec, diagram and gates:
specs/2026-09-23-ats-agent-browser-orders.md.
Modules in src/core/ats_contracts/: browser_order.ts (vocabulary and call),
browser_order_result.ts (result), browser_order_gate.ts (decisions) and the
internal browser_order_values.ts (shared value shapes, not re-exported).
| Tag | Shape |
|---|---|
aether.ats.browser-order-call/1 |
BrowserOrderCallV1, one of ten operations |
aether.ats.browser-order-result/1 |
BrowserOrderResultV1, status ok, refused or ambiguous |
verify_session, read_market, read_account, verify_ticket, read_order
and read_positions observe; prepare_ticket fills a ticket without
submitting it; commit_once and cancel_order mutate; end_control returns
control to the user.
Who may call it. Only the deterministic ATSv2 controller. A model never
names an operation, adapter or binding: its one order-shaped output is the
closed aether.ats.model-order-proposal/1 (section 2). The generic Agent
Browser MCP tools (browser_click, browser_type, …) are never an order path.
- No caller-supplied selector, coordinate, URL, script or free text. Every
call and parameter object is closed, and every string a caller can send is
a bounded identifier, digest, equity ticker, timestamp or enum member; a test
walks every string leaf. Tickers are equity-shaped (
SPY,BRK.B,BRK-B), deliberately narrower than the shared ticker, whose/and:let an all-caps string spellHTTPS://…. The adapter's recipe is code pinned byadapter_digest. - Every call names its principal, session generation, adapter, request and
deadline. The deadline follows
issued_atby at most 120 s, and by at most 30 s forcommit_onceandcancel_order. - ATS simulated paper never reaches a browser. A binding's environment is
provider_sandboxorprovider_liveand must equalenvironmentForSiteMode(site_mode), so a paper binding is never live capital and a live binding is never paper. - Only discovery and release run unbound. Every other operation needs a binding: id, generation, account fingerprint, site mode, environment.
- Results carry no page content. Page text, pixels, HTML, cookies and
account numbers are unrepresentable. The only site-derived strings are a
bare canonical https origin and a masked label drawn from a closed
character set — ASCII letters and digits, space,
. - _ ( ) # *, bullet and ellipsis — with at most four consecutive digits. A blacklist could not hold: fullwidth or other-script digits slip past a digit-run rule, and confusable letters or direction overrides can make the approval card lie. Order, cancel, account and position results name the account and mode they were read from, and positions count the holdings the contract cannot represent rather than silently dropping that exposure. - A login screen carries nothing. An unverified session has no origin, account, mode evidence or permission, and authentication leaves control with the user.
- Paper needs a site-specific indicator and, where the adapter has one, an
agreeing second indicator.
resolveSiteMode()returns null for an absent or contradicting second indicator andderiveBrowserBindingState()then yieldsobserve_only. A verified live session is alwayslive_locked. - The rendered ticket is digest-bound.
rendered_ticket_digestis recomputed on parse, so a changed field under an unchanged digest is refused. - A commit is never a fill.
commit_oncereturnsconfirmedwith the site order id, orsite_rejected, and cannot express fill facts. Fills come only fromread_orderwithorder_historyororder_detailevidence and must agree with the order status. - Unknown is first-class. Only the two mutations may be
ambiguous. A refused mutation promises that nothing was dispatched, andrequiresReconciliation()is true for every mutation that was not refused.duplicate_commitis legal only oncommit_once, and although that refusal dispatched nothing, it proves an earlier commit for the request exists, so it requires reconciliation too. - A result must answer exactly its call.
verifyBrowserResult()refuses another call, request or operation; a changed session generation; another user, agent or browser session; a changed adapter id, version or digest; an observation more thanMAX_CLOCK_SKEW_MS(5 s) older than the call, which is a replayed or cached answer; data that disagrees with the binding or parameters; and a read observed after its deadline. A mutation's outcome is never discarded for lateness, and when the gate refuses an answer to a mutation, ATSv2 treats that mutation asambiguous, because it journaledCOMMITTINGbefore it asked.
test/fixtures/ats_browser_order_golden.json holds 28 faithful exchanges
covering every operation, both site modes and all three statuses, with their
rfc8785/1 digests. It also holds 69 call rejects, 58 result rejects and 34
gate mismatches (189 vectors). Each negative vector is single-cause and names
the exact message of the check that must refuse it, and the TypeScript and
Python validators emit identical messages, so a vector proves it failed for
its stated reason in both languages. Coverage floors are named constants
equal to the coverage that exists, and every test collects all failing vectors
before asserting, so a broken guard names every vector it blinds. The file is
pure ASCII: non-ASCII vector values (a direction override, confusables,
fullwidth digits) are stored as JSON \u escapes, never as literal characters.
test/fixtures/ats_browser_order_wire.py and ats_browser_order_gate.py are an
independent Python mirror of the validators and decisions; they borrow only the
section 2 JCS encoder. ats_browser_order_verify.py runs every vector through
them, and CI runs it on Linux and Windows. It handles the traps a Python port meets:
re lets $ match before a trailing newline and \d match any Unicode digit
(use fullmatch and [0-9]), and JavaScript measures string length in UTF-16
code units. The fixture carries a vector for each. The timestamp validator
emulates V8's Date.parse exactly rather than Python's datetime: year 0000
is valid, month 13, day 32, minute 60, a leap second and 24:00 with any
non-zero part are "not a real instant", and 24:00:00 or a day past its month
rolls over and is "not a real calendar date". A differential probe of 37 edge
cases agreed on every message and millisecond. One asymmetry remains:
json.loads("4.0") is a float that Python refuses while JSON.parse yields the
integer 4, so producers must emit plain integers.
Each guard was verified by breaking it. Removing any of these in either language, corrupting a digest, eroding a coverage category, or stating the wrong reason fails both suites and names the vector:
- the binding environment check;
- the adapter pin check;
- the rule that only mutations may be ambiguous;
- the reconciliation rule, including
duplicate_commit; - the rule that only
commit_oncemay be refused as a duplicate; - the second-indicator rule;
- the label character set;
- the equity ticker;
- the stale-read check and the replay bound;
- the account checks on order and cancel results;
- the ticket digest check;
- the binding requirement;
- in Python,
fullmatch, UTF-16 length, the hour-24 rule and year 0000.
The module performs no I/O, and nothing registers these operations as a tool.
Agent Browser v0.2.2 implements none of the callee side: it has no lease,
takeover signal, session generation, persistent profile, origin allowlist or
drift detection. ATSv2's execution controller still requires broker-API truth
for a commit. Both are later gates in the source spec. ATSv2 lifts the Python
validators into ats-mcp in its companion change.
What was weak. The Spec 1 /1 shapes (section 2) check tickers with
symbol(), ^[A-Z0-9][A-Z0-9.^:=_/-]{0,39}$, which accepts URL-shaped
strings such as HTTPS://X. They check masked account labels with a
control-character rule plus an ASCII-only five-digit-run rule, which accepts
fullwidth or Arabic-Indic digits, confusable letters, direction overrides and
zero-width characters. The execution spec
(specs/2026-09-23-ats-agent-browser-remaining-gates-v2.md,
gate G0) requires closing both through a versioned change before those shapes
are reused for an executable order.
What changed. /1 is frozen and keeps accepting exactly what it accepted.
Six /2 schemas carry the same fields as their /1 counterparts; the only
semantic change is the ticker or label check:
/2 tag |
Shape | Only change from /1 |
|---|---|---|
aether.ats.model-order-proposal/2 |
ModelEquityOrderProposalV2 |
symbol must pass equityTicker() |
aether.ats.equity-order-intent/2 |
NormalizedEquityOrderIntentV2 |
symbol must pass equityTicker() |
aether.ats.operator-approval/2 |
OperatorApprovalReceiptV2 |
symbol must pass equityTicker() |
aether.ats.execution-receipt/2 |
ExecutionReceiptV2 |
symbol must pass equityTicker() |
aether.ats.delegated-trading-grant/2 |
DelegatedTradingGrantV2 |
every symbol_allowlist entry must pass equityTicker() |
aether.ats.account-binding/2 |
BrokerAccountBindingV2 |
masked_label must pass closedMaskedLabel() |
equityTicker() (^[A-Z]{1,6}(?:[.-][A-Z]{1,4})?$: SPY, BRK.B, BRK-B) and
closedMaskedLabel() (ASCII letters and digits, space, . - _ ( ) # *, bullet
and ellipsis, at most four consecutive digits) live in primitives.ts. They
are the helpers agent-browser-ats-order/1 introduced (section 4), lifted with
byte-identical regexes and messages: the browser fixture and its Python mirror
pass unchanged, and drifting either message fails named browser vectors. Each
/1 and /2 validator pair shares one body parameterized by schema tag and
check, so /1 keeps its messages and evaluation order. ATS_SPEC1_V2_SCHEMAS
lists the six tags beside ATS_SPEC1_SCHEMAS.
The order-review receipt keeps one version. It carries no ticker and no
masked label, and it cannot move between chains: its intent_digest is the
digest of the intent including the intent's schema_version, and the approval
binds the review by digest, so a review answers exactly the intent version it
was minted for. The executable gate tells a /2 chain from a /1 chain by its
four versioned members; a /2 review would be a new tag with no semantic
change.
Gate rule. verifyExecutableCommitAuthority() is the only gate that may
authorize an executable order. It decides in three steps, and each of its two
refusals is fixed text that ATSv2 pins:
-
Version. The intent, approval, account binding and grant must all be tagged
/2, or it refuses withEXECUTABLE_CHAIN_REFUSAL:Only an order chain whose intent, approval, account binding and grant are all /2 may authorize an executable order; any /1 member makes it a historical record.
-
Re-validation. A tag is only a claim: a
/1-validated document retagged/2in code still carries whatever it was validated with. So the gate re-validates every member (the four with their/2validators, the review withvalidateOrderReview, the usage withvalidateGrantUsage) and requires the clock and the resulting position notional to be whole, non-negative numbers. A NaN clock passes every expiry check and a NaN or negative position or usage passes every limit. Anything that fails refuses withEXECUTABLE_MEMBER_REFUSAL:An executable order chain member failed re-validation; only freshly validated /2 documents and well-formed inputs may authorize an order.
-
Verdict.
verifyCommitAuthority()'s logic, unchanged, run on the re-validated copies rather than on the caller's objects.
verifyExecutableApprovalChain() applies the same three steps to
verifyApprovalChain() over the intent, review, approval and clock. Their
inputs are typed as either version so a /1, mixed or retagged chain reaches
the gate and is refused there, not only at compile time. The /1 gates keep
their signatures, stay version-blind and never re-validate. They are marked
@deprecated and kept for historical records: a /1 chain is never
executable, however consistent. The Python mirror keeps the shared logic
private (_approval_chain_verdict, _commit_authority_verdict), so a caller
that lifts it can only reach the executable gates, and it fails closed on any
exception during re-validation. optionalBindingV2() is the /2 form of
optionalBinding().
Re-issuing a binding or grant as /2. A /2 binding or grant re-issued
from a /1 one MUST bump binding_generation or grant_version. Reviews and
approvals minted under the /1 document then fail the existing "Account was
re-linked after this order was reviewed." or "Grant changed after review."
refusals instead of carrying into a /2 chain.
Follow-up, not in this change: the approval restates the order terms the operator saw but does not bind the masked label shown on the approval card. A later version could bind a label digest, so a label that changes after approval is refused.
test/fixtures/ats_contracts_v2_golden.json is pure ASCII, sha256
05cc87082c641a4c14714bdcf66e1b227b105fac3920dff2542f3e37784eea21. Every
message in it is compared exactly in both languages. It holds:
-
canonical text and digest for one valid instance of each
/2document; -
8 strict variants that must stay accepted by
/2and by/1, because the closure narrows and must not overreach; -
12 single-cause rejects: lowercase
spyon each ticker-bearing document and a label with five ASCII digits, each with its pinned/1refusal, plus each/2validator given its/1tag; -
41
frozen_weaknessentries that the/1validator ACCEPTS and/2REFUSES:HTTPS://X,A:B,X/Y,ABCDEFG,BRK.BBBBB,A^Band1ABCon each of the five ticker-bearing documents, and labels with a fullwidth digit run, Arabic-Indic digits, a Cyrillic confusable letter, a right-to-left override (U+202E), a zero-width space (U+200B) or a byte order mark (U+FEFF); -
65 chain cases over 71 validated chain documents and 8 raw ones, and the two refusal texts. There are three kinds of case, each single-cause:
-
6 version cases: the faithful
/2chain; a/1intent, approval, binding or grant in an otherwise/2chain; and an all-/1chain. The version-blind gates accept every one, so the version rule is its only possible refusal. -
9 member cases hand the gates raw, never-validated documents and inputs that the version-blind gates accept:
- the retagged chain (a URL-shaped ticker and a direction-override
label, retagged
/2), a retagged binding and a retagged grant; - an unvalidated intent, review and approval;
- malformed usage, clock and resulting position.
Re-validation is their only refusal. Every raw document fails its own tag's validator, and each retagged one passes
/1when tagged back. - the retagged chain (a URL-shaped ticker and a direction-override
label, retagged
-
50 branch cases, one per reachable refusal branch of the shared chain and commit logic. Each is a valid
/2chain with a pinned message that both the version-blind and the executable gates must produce, in both languages. One pairs a review minted for the/1intent with the same intent retagged/2and must fail "Review does not answer this intent."; it pins the review-receipt argument above.
Three branches cannot be reached through a validated chain, so they have no case:
- "Approval and review disagree about the intent.": a backstop behind the first digest check.
- "Reservation expired.": a validated review's approval deadline never outlives its reservation, so the deadline check fires first.
- "Order environment does not match the grant environment.": the commit gate's own environment check fires first.
-
test/fixtures/ats_contracts_v2_wire.py mirrors the validators at /1 and
/2 and both executable gates, borrowing the section 4 primitives and the
section 2 JCS encoder. ats_contracts_v2_verify.py runs all 211 vectors, and
CI runs it on Linux and Windows. The frozen /1 label rule is written
[0-9]{5,} in Python, because Python's regex digit class, unlike JavaScript's,
matches fullwidth digits.
Each guard was verified by breaking it in both languages:
- Swapping the strict ticker for
symbol()in any one/2validator fails that document's eight ticker vectors, the raw-document check of each retagged copy of it, and any member case that copy alone breaks. Nothing else fails. - Swapping the closed label for the
/1label fails the six label weaknesses, theoptionalBindingV2()check, and the retagged binding's raw-document check and member case. Nothing else fails. - Removing either gate's version check changes the mixed-chain cases to the
member refusal, because re-validation then rejects the
/1tag. Removing it together with the re-validation flips them to accepted. - Removing either gate's re-validation, or any single member's, flips its member cases to accepted.
- Deleting any reachable shared-logic branch changes the outcome of its branch case. Deleting one of the three unreachable branches changes nothing, which is why they have no case.
- Deleting each target check flips every vector aimed at it to accepted, never to another refusal.
Scope. A passing fixture proves only that both sides agree on shapes and
refusals. Nothing here registers an order tool, and
test/ats_no_order_tool.test.ts proves that none of the CLI's 35 tool, action,
command and flag registries exposes one.
What counts as an order operation:
- any of the order contracts' 17 operations;
- an order, trade, position or ticket being submitted, created, placed,
cancelled, executed, closed, routed or amended, including forms run
together, such as
placeorder; - a buy, sell, short, trade, flatten, rebalance or liquidation;
- an approval;
- in an ATS-scoped registry, a bare submit, place, commit or cancel.
Three reviewed exemptions, each with a reason, cover the GitHub action rail's
--approve flag and the ATS settings mode approve. The list is exact: it
cannot grow silently, and no order-contract operation can be exempted. That
inventory covers what this CLI registers, advertises and dispatches. Tools
offered by a user-configured MCP server or by Cloud's MCP broker are outside
it, and the CLI's ToolExecutor refuses any name outside TOOLS. The G0
release matrix, including the ATSv2 pins, is
specs/2026-09-23-ats-browser-execution-release-matrix.md.
- Aether Code private host protocol (
aether.code.host/1): canonical supervision, handshake, credential-boundary, and host-action contract isAETHER_CODE_HOST_PROTOCOL.md. It explicitly reuses the unchangedaether.exec/2execution stream andaether.exec.control/2control stream. - Universal UVT stream (chat/orchestrator/MCP SSE): owned by the Aether
platform; surfaced here by
src/core/stream.ts. The bridge'sCloudBrainmaps that vocabulary onto the event protocol above. - CLI auth (device flow +
aek_PAT): the CLI↔platform auth contract; seesrc/core/device.ts.
Step 1 of the E1 landing order in
specs/2026-09-22-managed-ats-tool-host-v1.md
(section 17): the common encoding and the closed schemas for trust, device
proof, host-open proof, observer-channel receipt, runtime capability,
registry, host lease, invocation, cancellation, result and workspace status.
It adds no transport, no I/O and no Cloud call. No tool is registered, and
nothing here grants execution authority.
contracts/managed-ats-tool-host/v1/:common.schema.json($defsonly,x-aether-schema-idaether.managed-tool-common/1), the trust, device-proof, host-open-proof, observer-channel-receipt, runtime-capability, registry, host-lease, invocation, cancellation, result, workspace-status-input and workspace-status schemas, andmanifest.json(aether.managed-tool-schema-bundle/1: one entry per file, sorted by file, with itsschema_digest). All are JSON Schema draft 2020-12 with$idhttps://schemas.aethersystems.net/managed-ats-tool-host/v1/<file>, every object closed with a fullrequiredlist, and ASCII only. Every$refis document-local: each schema carries an identical copy of the common definitions it uses (the test proves the copies matchcommon.schema.json), so a schema digest covers everything that validates against it. The schemas document structure; the semantic rules below are enforced by the validators.src/core/managed_tool_host/: a closed validator for each object, the strict frame lexer, digests and derivations, Ed25519 over raw 32-byte keys, and the cross-object checks, exported fromindex.ts. Every refusal is aToolHostContractErrorwhose fixed message names the field path, never the value. Imports are limited tonode:crypto,../ats_contracts/canonical.js(the single RFC 8785 encoder) and sibling files.test/fixtures/managed_tool_host_golden.json,test/managed_tool_host_contract.test.tsand the independent Python mirrortest/fixtures/managed_tool_host_{wire,objects,cross,ed25519,verify}.py, which CI runs on Linux and Windows..gitattributeskeeps the fixture and the schema bundle at LF.
The spec leaves these implicit. The lead decided them, and both languages implement exactly this.
- Common digest:
sha256:+ hex(sha256(ASCII(schema_id) + LF + JCS(object without its own digest or signature fields))). It coversproof_digest(which omits bothproof_digestandcloud_signature),receipt_digest,capability_digest,registry_digest,invocation_digest,cancellation_digest,result_digestandstatus_digest. The trust document has no digest. - Signatures are pure RFC 8032 Ed25519 (no prehash) over ASCII(schema_id) +
LF + JCS(object without the signature field only). The lease and the
device proof omit only
cloud_signature, so the device-proof signature bindsproof_digest. The host-opendevice_signaturesigns ASCII(aether.managed-tool-host-open/1) + LF + JCS of challenge, device_proof_digest, agent_id, conversation_id, local_session_id, session_generation and registry_digest. That prefix differs from the proof object's own schema,aether.managed-tool-host-open-proof/1. arguments_digestuses the prefixaether.managed-tool-arguments/1.- A schema digest uses the prefix
aether.schema/1over the whole document. common.schema.jsoncarriesx-aether-schema-idaether.managed-tool-common/1and holds$defsonly.- Timestamps match
YYYY-MM-DDTHH:MM:SS.mmmZexactly and must be real Gregorian instants: years 0001 to 9999, hours 00 to 23, no leap second. Both languages convert them to epoch milliseconds with explicit days-from-civil arithmetic, neverDate.parseordatetime.fromisoformat. - Integers are 0 to 2^53 - 1 unless a field is narrower:
session_generationandsequenceare 1 to 2^53 - 1,revocation_epoch0 to 2^53 - 1. - Strings default to at most 256 Unicode scalar values (code points, not
UTF-16 units). Every Cc control (U+0000 to U+001F, U+007F to U+009F) and
every unpaired surrogate is refused anywhere, keys included. Display
text follows the stricter
aether.safe-display/1rule below. - Set-like arrays (
supported_read_operations,dependencies,data_classes,capabilities,evidence_refs, and trustkeysbykey_id) are strictly ascending by code point, with no duplicates. Tools are strictly ascending by (name, version), with versions compared as integers. An out-of-order array is refused, never reordered.diagnosticskeeps its order. - Base64url is unpadded: exactly 43 characters for 32 bytes and 86 for 64. It must be canonical (the unused low bits of the last character are zero), so each byte string has exactly one spelling.
- Temporal checks take an explicit
nowin epoch milliseconds and allow 30 000 ms of skew either way. Pure-shape validators take no clock. - Cross-object checks are separate exported functions (listed below).
- The raw frame lexer works on bytes. It refuses a UTF-8 byte order mark,
invalid UTF-8, frames over 262 144 bytes, nesting deeper than 16,
duplicate members (compared after unescaping), any number other than
0or[1-9][0-9]*up to 2^53 - 1 (so-0,01,1.0,1e2and-1all fail lexically), and any control character or unpaired surrogate in a string, raw or escaped. Arguments are further limited to depth 8. - Every refusal is a
ToolHostContractErrorwith a fixed message. The TypeScript and Python messages are byte-identical, and the vectors compare them exactly.
The lead decided these after an independent review of the bundle. Both languages implement exactly this, and each rule is pinned by vectors.
- Ed25519 per RFC 8032, cofactorless verification, S < L, canonical point
encoding, small-order keys refused. A trust key or
device_public_keyis refused unless its encoded y, with the sign bit masked, is below p = 2^255 - 19 and it is not in libsodium's small-order blocklist (seven encodings, compared with the sign bit masked).ed25519Verifyrefuses such keys as well, so a device proof that skipped validation still cannot admit the review's forgery (R = identity and S = 0 under the all-zero key). - Trust expiry at every use:
verifyCloudSignaturetakesnowand refuses oncenowis at or past the trust document'sexpires_at+ 30 s. A cached trust document cannot verify a device proof or a lease after it expires. aether.safe-display/1:error.messageanddiagnostics[].summaryare 1 to 256 printable ASCII characters (U+0020 to U+007E). The rule is identical in both languages and immune to Unicode-table drift. It refuses right-to-left overrides, line and paragraph separators, zero-width characters, byte order marks and invisible tag characters. A result'sredaction_profilenames this rule.account_subjectkeeps the general string rule (rule 8), because it is hashed locally and never displayed.- Revocation fencing:
checkLeaseBindingrequires the lease'srevocation_epochto equal the device proof's (device-level fencing). Lease-level fencing islease_idplussession_generation. checkHostOpenBinding(hostOpen, registry, lease, {challenge}): the challenge equals the one Cloud issued;agent_id,local_session_id,session_generationandregistry_digestequal the registry's; andconversation_id,agent_id,local_session_idandsession_generationequal the lease's.device_proof_digestis bound to the device proof byvalidateHostOpenProof, which takes that proof.- Byte bounds count UTF-8 bytes: the frame limit,
bounded_bytesandmax_argument_bytescount bytes of the UTF-8 or canonical form, never characters or UTF-16 units. Multibyte vectors pin each one. assertE1CanaryRegistryrequiresdata_classesto be exactlyats_status, local_status: spec section 2.4 keeps browser observation out of E1.- Both harnesses pin the fixture's LF-normalized sha256, and the TypeScript test requires this document to state it.
- Result rules. State
cancelledrequireserror.codeTOOL_CANCELLED anddeadline_exceededrequires TOOL_DEADLINE_EXCEEDED.refusednever uses either code, andunavailableis not constrained.succeedednever usesretry_classnew_call_after_recovery.checkResultrefuses a succeeded result whosecompleted_atis later than the invocation'sdeadline_at- 30 s: the deadline wins.
- Redelivery (no validator change). A transport retry of an undelivered
result POST resends the byte-identical stored body. Answering a replayed
invocation whose result is already stored returns that stored result
re-sealed with
replay_statusstored_redelivery andretry_classredeliver_stored_result: the same payload, state, error, evidence, timestamps andbounded_bytes, with a recomputedresult_digest. Cloud keeps the first result it stored. - Hosts map every lexer or validator refusal to TOOL_CONTRACT_INVALID and never branch on refusal message text. For a frame with two defects the two languages can name different defects (see below).
- The module scan refuses any I/O import, including a bare side-effect
import such as
import "fs";, and any use ofprocess..
Where the spec and the lists above were silent, this lane made the following choices. Each is pinned by vectors, so changing one requires a new fixture.
cloud_origin_idis a normalized lowercase https origin, the same value the account-scope derivation uses: a dotted host, no IP literal, and no path, query, fragment, credentials or explicit :443.- Every
device_id(device proof, registry, lease, invocation, binding) must use thescdev_namespace. channel_idfollows the ID grammar. The protocol nameaether.ats.observer-channel/1is exported as a constant but no field carries it.runtime_versionis 1 to 64 characters from U+0020 to U+007E, and a diagnosticcodematches[A-Z][A-Z0-9_]{0,63}.- Freshness: an object is refused when its start is more than 30 s after
now, or whennowis at or pastexpires_at+ 30 s. The cancellation window runs fromlease.issued_at- 30 s (inclusive) tolease.expires_at- 30 s (exclusive).
- Open JSON values (invocation arguments, and a result payload at most 15
levels deep inside its frame) allow only unsigned safe integers and use
the common string (256) and array (32) bounds. Invocation arguments are
also capped at 65 536 canonical bytes before the registered
max_argument_bytesapplies. - Only the frozen E1 schemas have argument and payload validators
(
checkToolArguments,checkToolPayload). Any other registered schema fails closed.assertE1CanaryRegistrypins both E1 schema digests as well as their IDs. - Beyond clarification 12 and the review's host-open binding, implementing
spec sections 4.2, 7, 8 and 10:
checkCapabilityReceipt, the argument and payload dispatchers, and the lease scope equalities incheckLeaseBinding(account, agent, device, local session, generation, registry digest, origin), which sit alongside the expiry bounds. - The 64 KiB workspace-status bound runs first, as a resource bound on the serialized value. A status that is otherwise valid cannot exceed about 20 KB, so the vector for this bound always breaks a shape rule too.
- The spec does not rank two defects in one frame. The TypeScript lexer
reports the first defect in document order, grammar and duplicate members
included. The Python mirror reports its pre-scan defects (depth, number
spelling, controls and surrogates) before grammar errors and duplicate
members, because
json.loadsruns after the pre-scan. Every raw reject vector has exactly one defect, so the vectors hold both languages to the same message, but a frame with two defects can be refused with different messages (hence decision 11). - Not checked here, because they need state across calls: consumption of
max_calls, and monotonic invocationsequence.
checkLeaseBinding: scope, revocation epoch and expiry against the registry, device proof and trust document.checkHostOpenBinding: the issued challenge, the registry's session and the lease's conversation.checkInvocation: scope against the lease, the lease's registry binding, tool selection by (name, version), input schema identity, the registered argument byte bound, the deadline against both the lease expiry andmax_duration_ms, and the deadline not yet passed at claim time.checkToolArgumentsandcheckToolPayload: dispatch to the frozen E1 input and output validators.checkCancellation: the targeted call and its digest, lease fencing, and the lease window.checkResult: identity with the invocation, output schema identity when succeeded, the registeredmax_result_bytes, start and completion times, and the invocation deadline for a succeeded result.assertE1CanaryRegistry(tool, version, schemas, dependencies and data classes) andcheckCapabilityReceipt.
test/fixtures/managed_tool_host_golden.json is 506 945 bytes. The sha256 of
the committed LF blob is
eea8337d0c4caf0fe167ef5c7a144468118d34ee79a24b811d67212243688f60. The file
is pure ASCII: non-ASCII values are stored as JSON backslash-u escapes. It
holds:
- 4 test keys, with seeds labelled NOT FOR PRODUCTION;
- the 13 schema digests;
- 6 canonical vectors and 151 primitive boundaries, among them every blocklisted Ed25519 encoding and the safe-display rule;
- 16 derivation rows;
- 13 accepted and 56 refused raw frames, multibyte ones included (large frames are small generator specs);
- 45 accept and 378 reject object vectors: trust 30, device proof 39, host-open proof 20, observer receipt 21, runtime capability 24, registry 49, host lease 24, invocation 37, cancellation 13, result 59, workspace-status input 5, workspace status 57;
- 26 accept and 99 reject cross-object vectors.
Object and cross-object reject vectors are resealed (digests and signatures
recomputed) so that only the target defect remains. Vectors whose defect is
the digest or signature itself are not resealed, and neither are
unknown-field vectors, since a digest covers only the declared fields. The
single-cause proof covers all 656 refusals in the fixture: 378 object, 99
cross-object, 56 raw frame, 114 primitive and 9 derivation vectors. Each
vector was run against a build whose fail() ignores exactly that vector's
message, which deletes its target check. For the invalid UTF-8 vectors,
deleting the check means decoding leniently. 608 vectors then flip to
accepted. The other 48 carry an exception tag naming why they cannot flip:
- type guards (18);
- JSON grammar violations (16), which have no guard to delete. They are not run this way, because ignoring the generic grammar message would also remove the lexer's loop exits;
- a key or tool lookup with no accept path (7);
- unpaired surrogates that RFC 8785 cannot encode (3);
- wrong-length signatures, which then fail verification (2);
- seven dependencies from a six-member set (1);
- the 64 KiB bound above (1).
The forged host-open vector hands validateHostOpenProof a device proof
that skipped validation (validation now refuses its key), so it proves the
key screening inside ed25519Verify itself. device_proof.expired is
checked against a later trust document with the same keys, because the main
trust document has expired by then.
Two lexer details keep the raw frame vectors single-cause. Both decoders would strip a leading byte order mark, so the explicit guard, which runs first, is the only rule that refuses one. Every escape is decoded to its UTF-16 unit before the control and surrogate rules run on that unit, so a letter escape and a four-hex-digit escape of the same control character are refused by the same rule.
test/managed_tool_host_contract.test.ts (19 tests) recomputes every digest
and signature with node:crypto and the repo encoder, and compares every
refusal message exactly. It also checks:
- that the fixture's LF-normalized sha256 equals the pinned value, which this document states;
- that vector ids, names and key labels are unique within each section, since both harnesses look vectors up by id;
- closed-field and enum parity between each schema document and the validator's field lists;
- that
grants_execution_authorityis required andconst: falseexactly where the spec names it,execution_authorityisnoneandorders_enabledis false; - the manifest and the pinned E1 digests;
- named coverage floors equal to the frozen counts;
- a scan of the module for I/O (bare side-effect imports and any
process.use included), disallowed imports and non-ASCII bytes.
The Python mirror shares no code with TypeScript. Its lexer is json.loads
with duplicate and number hooks plus a pre-scan in document order. The
pre-scan is the only rule for control characters inside strings, since
json.loads runs with strict=False. The mirror's
RFC 8785 encoder sorts keys by UTF-16 code unit. Its Ed25519 is pure RFC 8032
and also refuses an S at or above the group order. managed_tool_host_verify.py
reproduces all 807 checks and exits non-zero on any mismatch. It prints each
failure as ASCII and records a crash inside a section as a failure, so a
failing run on any console still names the vectors that caught it.
A mutation matrix, run with a throwaway script outside the repository, deleted or inverted one guard at a time in a copy of the built module and of the Python mirror: 63 guards in TypeScript and 66 in Python, including 20 per language for the review round. All 129 guard mutations failed their suite, naming the vector aimed at that guard, and in 120 of them that vector was then accepted. The other nine are:
- the 64 KiB bound in both languages (the tagged vector above);
- the mirror's RFC 8785 key sort, which a canonical vector catches;
- two derivation changes in both languages, the host-open signing prefix and the integer version order. The accept vector built on each derivation is refused as well;
- counting characters instead of UTF-8 bytes in both languages. The
multibyte result accept vector catches it first, because its
bounded_bytesstops matching the payload.
Seven further mutations fail as well. Four fixture edits fail in both
harnesses: a vector dropped below its floor, a changed stated reason, a
repeated vector id, and an edited note that breaks the sha256 pin. Three
checks fail in TypeScript: a bare import "fs"; and a process. use in the
module scan, and a changed fixture sha256 in this document.