From 03059fcc96770308c3b0ffaa60f548ad3a9de51f Mon Sep 17 00:00:00 2001 From: tetsuo Date: Thu, 12 Nov 2026 23:16:51 +0900 Subject: [PATCH] feat: add correlated evidence tooling for autonomous pilot --- .github/workflows/ci.yml | 2 + docs/compatibility/autonomous-pilot.md | 384 +++++ docs/implementation-plan.md | 12 + docs/notifications.md | 11 + docs/references.md | 11 + docs/release-preflight.md | 18 + package.json | 3 +- scripts/autonomous-pilot.mjs | 300 ++++ scripts/autonomous-pilot.test.mjs | 278 ++++ .../host/autonomous-evidence.template.json | 100 ++ test/e2e/host/autonomous-evidence.ts | 1415 +++++++++++++++++ test/unit/autonomous-evidence.test.ts | 914 +++++++++++ 12 files changed, 3447 insertions(+), 1 deletion(-) create mode 100644 docs/compatibility/autonomous-pilot.md create mode 100644 scripts/autonomous-pilot.mjs create mode 100644 scripts/autonomous-pilot.test.mjs create mode 100644 test/e2e/host/autonomous-evidence.template.json create mode 100644 test/e2e/host/autonomous-evidence.ts create mode 100644 test/unit/autonomous-evidence.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e096424..c7fe0097 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: run: npx biome check scripts/manage-ingress-waf.mjs scripts/manage-ingress-waf.test.mjs scripts/wrangler-sqlite.test.mjs scripts/release-preflight.mjs scripts/release-preflight.test.mjs scripts/require-release-approval.mjs scripts/require-release-approval.test.mjs scripts/release-authorization.mjs scripts/release-authorization.test.mjs scripts/release-provider.mjs scripts/release-provider.test.mjs scripts/release-postverify.mjs scripts/release-postverify.test.mjs scripts/verify-release-credentials.mjs scripts/verify-release-credentials.test.mjs scripts/inbox-streams-workerd.test.mjs scripts/two-worker-subscriptions.mjs scripts/restore-transition-workerd.test.mjs - name: Lint normal-pause readback run: npx biome check scripts/release-pause-readback.mjs scripts/release-pause-readback.test.mjs + - name: Lint autonomous pilot evidence tools + run: npx biome check scripts/autonomous-pilot.mjs scripts/autonomous-pilot.test.mjs - run: node --test scripts/*.test.mjs - uses: actions/setup-python@v5 with: diff --git a/docs/compatibility/autonomous-pilot.md b/docs/compatibility/autonomous-pilot.md new file mode 100644 index 00000000..fb63d8df --- /dev/null +++ b/docs/compatibility/autonomous-pilot.md @@ -0,0 +1,384 @@ +# Two-owner idle-host autonomous conversation pilot + +**Status: BLOCKED / NOT RUN.** The evidence procedure and its deterministic +validator tests do not establish an actual host wake-up. There is no passing +real-host record for Issue [#24](https://github.com/kitamura-tetsuo/agent-table/issues/24). +This gate requires independently authorized owners A and B, actual target +hosts, an approved isolated deployment, and observable event-to-run-to-reply +traces in both directions. + +Implementation baseline: `381c4e7378b2d95a3b8342f045a3e4d5521ada00`. +The existing [guest OAuth record](guest-oauth.md) establishes bounded tools +interoperability for one tested ChatGPT Work private connection. It supplies +neither a second owner's consent nor notification or autonomous-run evidence. +Do not transfer its accepted provenance limitations to this pilot: if a new +host exposes no sufficient event/run association, that scenario stays blocked. + +## Current prerequisites + +| Boundary | Known state | Needed for this pilot | +| --- | --- | --- | +| Durable delivery, #21 | Server implementation and controlled-receiver regressions exist | Exact deployed candidate, active subscriptions and actual provider receipts | +| Operations, #22 / #51 | Independent restore reconciliation remains open | Approved isolation, current authoritative state and applicable operational gates; no unapproved quarantine clearance | +| Protected release, #50 | [Paused manual production release accepted](https://github.com/kitamura-tetsuo/agent-table/issues/50#issuecomment-6825493911) on 2026-11-12 | Separate private pilot activation; the manual release does not authorize Events or autonomy | +| Host OAuth, #23 | One scoped actual-host tools record accepted | Two independently controlled eligible accounts, each owner's approval and that host's current tools evidence | +| Host execution | No observed idle-host round trip | Supported idle/run/event observation surface for each selected product, surface and account | + +At this baseline `src/notifications/egress.ts` returns literal `enabled: false` +from `callbackTransportReadiness()`. Hosted `isRestoreQuarantined()` in +`src/shared/release-quarantine.ts` admits only `RELEASE_PROFILE=manual` with +all three subscription/Events/outbound flags false. The protected +`release-authorization.mjs` path also supports the manual profile only. Setting +environment flags or obtaining a subscription ID cannot make the current +deployment an autonomous pilot. +A reviewed private activation change and its concrete deployment approval are +prerequisites; the offline evidence tools below do not implement or authorize +them. Keep public Events advertisement and production configuration unchanged +through this offline work. + +A storage-preserving restart of current authoritative state, without restoring +a historical snapshot, is distinct from Issue #51's PITR/restore-quarantine +clearance. Do not claim that #51 must be completed merely to observe such a +no-restore pilot restart. Issue #24 still lists #22 as a dependency; reconcile +that outstanding scope explicitly while satisfying the actual approved +isolation and restart requirements. Neither this procedure nor the existing +manual scripts can deploy or authorize an autonomous profile. + +Issue #19's owner-approved limited-beta assumption permits evaluating native +hosted Workers fetch within its recorded residual risk. Do not reopen the +unavailable same-connection IP/TLS proof as a new pilot condition. The +[reviewed callback profile](../events-provider-wire.md), real callback control, +signature interoperability, bounded canary and stop controls still need their +scoped staged-rollout evidence. No public IPv4 receiver is required by this +procedure, and a controlled receiver cannot substitute for either real host. + +### Proposed private staging activation boundary + +This proposal is a separate, default-off implementation prerequisite. Add an +explicit **staging verification profile**, distinct from public Events or an +autonomous release: the approved trial gathers missing host evidence and must +not require that same evidence to exist before the trial can start. + +Bind its approval to the exact candidate SHA, configuration digest, isolated +resources, finite window, selected guest connections/tables and reviewed +callback origin/path prefixes. Keep credentials outside that record. Two +actual hosts and their independent owners must be selected before the +provider-specific fields and observation surfaces can be finalized. + +Use one shared activation policy across the existing +[quarantine guard](../../src/shared/release-quarantine.ts), +[release authorization](../../scripts/release-authorization.mjs), +[approval check](../../scripts/require-release-approval.mjs), +[protected workflow](../../.github/workflows/_deploy-reviewed.yml) and +[post-verification](../../scripts/release-postverify.mjs). Apply that same +policy to [transport readiness](../../src/notifications/egress.ts), authenticated +[discovery/status](../../src/mcp/server.ts) as required by the selected host, +and fresh [TableDO dispatch](../../src/objects/table.ts) authorization. +Global support advertisement must remain disabled. + +Reuse current guest, membership, lifecycle, subscription-generation and retry +checks; approval adds no replacement authority. Expired/removed activation or +the existing outbound kill switch must deny new dispatch without resetting +retry state. Preserve manual production behavior and the no-restore boundary. +This PR supplies neither that policy nor an enabled profile. Its implementation +and concrete staging approval follow host/account selection and review. + +## Select the concrete run before requesting activation + +The operator records the candidate commit, effective configuration digest, +provider deployment/version reference, isolated endpoint reference, test date, +protocol revision and notification profile. Independently read back the +actual deployment; a checked-out commit is not a deployment observation. +The registry retains redacted observation references and digests, rather than +credentials or endpoint/callback URLs. + +Each owner supplies and approves their own: + +- Host product, surface and exposed version/build identity. If a SaaS build is + not exposed, record that restriction explicitly with the supported + observation; do not invent a version. +- Account/workspace eligibility and supported notification profile, verified + for this account. Provider documentation or a connector badge is insufficient. +- Guest identity, connection, membership and subscription, recorded with stable + pseudonyms that preserve equality across this one pilot. Distinct pseudonyms + must reflect actual distinct owners and identities, not two tabs belonging + to one owner. +- Bounded instructions, synthetic input and stop limits, independently approved + before that host is used. A's permission cannot authorize B's account. +- Supported host audit/export or UI evidence that establishes no active run, + a later new run and the event that initiated it. A model's own report of its + run, a background process or a new message timestamp is insufficient. + +Resolve these inputs before staging activation. Do not guess an account, +workspace, callback destination or provider API. If the selected host cannot +expose a causal run observation, record the exact missing signal as blocked. +Manual setup is allowed; a human wake-up prompt after the initial post is not. + +### Owner-approved standing instructions + +Each owner independently reviews this bounded instruction text and applies it +through their own host's supported controls. Peer messages remain untrusted +conversation content, never privileged instructions for the other host. + +> Use only the synthetic information explicitly supplied for this pilot and +> this table. Do not import personal memory, other chats, contact details or +> credentials. On a genuine peer event, authenticate with your own connection, +> read the current table and the relevant transcript, then make at most one +> useful reply to the peer message. Retain the original `request_id` and exact +> inputs if a mutation result is uncertain; do not generate a new key for its +> retry. Set `reply_to_message_id` to the message being answered. Do not send +> repeated acknowledgments. Stop when the scenario's post/time limit is reached, +> the table is terminal, the subscription is removed, permission is refused or +> there is no substantive reply left. Ask your own owner before exceeding this +> delegation or taking any action outside this synthetic table exercise. No +> agreement here authorizes purchases, messages to other people or other +> external effects. + +For the initial round trip, allow A at most two accepted posts and B at most +one, with an owner-chosen finite observation deadline recorded before starting. +A's synthetic choices are slot `09:00` or `11:00`; B's are `10:00` or `11:00`. +A posts its two choices, B proposes the common slot, and A confirms the single +common slot without another acknowledgment from B. These are fictional labels, +not a calendar booking. Limits bound accepted relay traffic, not hidden model +reasoning or total provider charges. Owners must also approve the scoped +cancellation, restart and isolation phases in advance, with aggregate reply +and time limits covering the planned additional turn. Do not silently reuse +exhausted authority to continue after the first phase. + +## Capture and join evidence + +Create one private table through the existing independently authorized browser +or MCP flow, join its second seat with explicit consent, and establish both +subscriptions before the initial post. Keep invitation secrets private. +For the Events profile, `events/list`, `events/subscribe` and +`events/unsubscribe` are authenticated protocol methods, not MCP tools. Refresh +uses `events/subscribe` with the original compound subscription identity; +there is no ID-only renewal method. Follow the pinned provider wire profile +rather than constructing a new callback API. + +`get_table` reports the caller's own connection-local notification state. +Capture separate A and B readbacks and subscription verification receipts; +A's status cannot establish B's state. Generic operational telemetry excludes +private table/event/principal identifiers and does not observe host runs. +Its counters cannot reconstruct the following trace by themselves. Use actual +authorized tool receipts, supported host observations and narrowly scoped, +independently authorized server readback where needed. If a committed event or +dispatch authorization cannot be observed, leave it unknown; do not broaden +ordinary telemetry or invent a diagnostic endpoint to fill the gap. + +| Observation | Required association | Authoritative source | +| --- | --- | --- | +| Setup | Same pilot, deployment/config, two owners and active subscriptions | Independent consent, deployment and authenticated subscription observations | +| Committed post | Table, sender membership, message ID, ordered sequence and time | Durable post receipt and authorized transcript readback | +| Committed event | The same message/sequence/table, peer subscription and recipient | Server outbox observation | +| Receipt | The same event and subscription, acceptance result and time | Safe sender observation joined to actual receiver observation | +| Idle and new run | Idle target with no active run, then a distinct run initiated by that event | Supported host audit/export or independently recorded host UI | +| Authenticated read | That run, event, table, receiving membership/connection and snapshot sequence | Host tool trace joined to the authorized server result | +| Committed reply | That run and table, recipient as sender, original message as reply parent, later sequence | Host tool receipt and authoritative transcript readback | + +1. Establish B's idle/no-active-run state after both subscriptions are active. + Instruct A once to make the initial synthetic post. Record its committed + receipt and the matching peer event; a failed attempt is not a post. +2. Observe B's receiver acceptance, a newly started B run caused by that event, + the authenticated read containing the source message, and B's committed + reply. Do not click Continue, copy a message to B, poll for B or run a model + through a replacement script. If intervention is needed, preserve the + failed/blocked record and stop this autonomous scenario. +3. Establish that A's initial run has ended and A is idle before B's reply event + is accepted. Capture the corresponding new A run, authenticated read and + committed reply. The return event must reference the exact B reply from the + first turn, on the same table, not a separately initiated test. +4. Read the authoritative ordered transcript. Check distinct memberships, + message identities, reply parents and sequences against both traces. + Record any duplicate deliveries and their actual run/reply behavior. A + receiver accepting twice does not prove two runs; one reply does not prove + the host reasoned only once. + +If B replies before A is idle, the run does not prove the required return +wake-up. Preserve that observation and use a separately approved bounded +scenario whose selected host supports observing this boundary. Do not silently +replace the trace or treat an ongoing A run as a new idle-host run. + +### Exact mutation-key retry + +Within an owner-authorized synthetic scenario, retry one `post_message` with +its original `request_id` and unchanged body/reply parent after an uncertain +response. Keep the mutation key itself outside the public evidence; retain a +stable key digest/reference. Join both receipts to the same accepted message +and sequence, and compare transcript counts immediately before/after the +retry while no unrelated post is allowed. The retry must not add an accepted +message or a new message-created event. Deliberately creating a second key +tests a different operation and cannot satisfy this check. + +### Cancellation with pending work + +Use a separate bounded synthetic run so cancellation does not reopen the +round-trip table. Record a real pending event and its subscription generation, +then commit either the authorized table closure or that subscription's +unsubscribe through the supported entry point. Retain the terminal decision, +its timestamp and the before/after dispatch authorization observations. + +Observe through a declared finite deadline and verify no new delivery is +authorized after that decision. Distinguish work authorized earlier but not +yet dispatched from requests already dispatched at the decision. Preserve +authorization, dispatch and completion timestamps separately, including later +outcomes; authorization alone is not proof that bytes had already left. +Do not claim requests were recalled. An empty event list with no proven pending +work, or no defined observation interval, does not establish this scenario. + +### Retained-state restart + +Use a still-live synthetic table with active subscriptions and an independently +reviewed storage-preserving service restart. Capture the existing table, +subscription identities/generations, retry counts and retry-horizon state, +then preserve the same backing storage across the actual runtime transition. +Record before/after deployment/runtime observations and read back the retained +state. A new table, erased persistence directory, refreshed retry budget or +new subscription cannot stand in for the original state. + +After the restart, an actual peer agent posts a fresh eligible message. Join +its new event through receiver acceptance, a genuinely new idle-host run, +authenticated read and committed reply. Also retain a separate terminal table +or terminal subscription control and prove the same recovery path does not +revive its authority. Follow the approved terminal case; browser-confirmed +deletion requires that owner's explicit action. A same-storage restart does +not authorize historical snapshot restore or quarantine clearance. Missing +independent post-snapshot authority remains governed by Issue #51. + +### Third-guest isolation and disclosure + +Use a separately authorized third guest C with no membership in the pilot +tables. With the known synthetic table/inbox identifiers, exercise `get_table`, +`read_messages`, the own-inbox resource, and renewal/unsubscribe attempts for +each participant's subscription through the actual authenticated protocol. +Use the genuine compound subscription form privately, without copying signing +secrets into the evidence. A malformed request or missing scope proves only +that earlier rejection, not the membership/ownership boundary. Record denial +at the intended boundary and verify neither subscription changed. + +Check that errors reveal no private metadata, transcript or callback settings. +C must not gain access merely from knowing a table, inbox or subscription +identifier. Each owner independently checks the deliberately supplied +synthetic content against the accepted transcript; record bounded disclosure +observations without importing raw conversation text into the report. This +check covers content crossing the relay, not either host's private reasoning. + +## Offline collection and evaluation + +The Node version pinned in `.node-version` runs the local CLI. No command in +this section contacts a host, starts a model, mutates a table, changes a +subscription, deploys a Worker or enables a capability. + +```sh +npm run probe:autonomous -- init --output /tmp/pilot.json +npm run probe:autonomous -- evaluate \ + --input /tmp/pilot.json --output /tmp/pilot-report.json +``` + +The fresh template is structurally valid but **not-run**, so evaluation exits +nonzero. Populate it only from actual retained observations. The strict schema +is `test/e2e/host/autonomous-evidence.ts`; the checked-in template is +`test/e2e/host/autonomous-evidence.template.json`. The observation registry uses +`id`, source category, capture time and SHA-256 digest for separately retained, +redacted receipts. Stable pseudonyms preserve joins within the pilot. No +secret, cookie, authorization header, invitation, callback URL, raw transcript +or private unrelated chat belongs in this registry or its input fragments. + +For separate captures, prepare strict JSON fragment envelopes of +`{ "schemaVersion": 1, "pilotId": "...", "sections": { ... } }`. Supply every +required top-level evidence section exactly once across the fragments; +observation arrays may be concatenated only with unique IDs. Setup captures +normally contain deployment/participants, turn captures the correlated turns, +and boundary captures retry, duplicate delivery, cancellation, restart, +isolation and disclosure. Do not use fragments to overwrite a failed or missing +observation with a claimed pass. + +```sh +npm run probe:autonomous -- collect \ + --fragment /tmp/setup.json \ + --fragment /tmp/turns.json \ + --fragment /tmp/boundaries.json \ + --receipt-dir /private/redacted-receipts \ + --output /tmp/pilot-evidence.json \ + --report /tmp/pilot-collected-report.json +``` + +Each registered receipt must already exist as a reviewed, sanitized +`.json` in the private receipt directory. The CLI checks the +registered digests; it does not redact a raw log or authenticate who produced +a receipt. Review retained contents independently before importing them. +Keep raw originals and the private pseudonym mapping outside the publishable +artifact. File integrity alone cannot establish that a real host ran. + +The evaluator requires comparable timestamps for its causal checks. Preserve +original timestamps and clock-source limitations in the retained receipts. +If different clocks cannot establish the claimed order, keep the affected +scenario unverified; do not adjust times to manufacture correspondence. +These checks do not assert a new server wall-clock monotonicity guarantee. + +```sh +npm run probe:autonomous -- evaluate \ + --input /tmp/pilot-evidence.json \ + --receipt-dir /private/redacted-receipts \ + --output /tmp/pilot-reviewed-report.json +``` + +Output destinations are exclusive, owner-only files: choose a new path for +each capture/evaluation instead of overwriting previous evidence. Collection +and evaluation emit the safe report to standard output and the report file. +Exit `0` requires a complete record with matched local receipts; `1` means +incomplete/blocked and `2` means invalid input or an I/O failure. A receipt +directory may be omitted for an incomplete evaluation; omitting it always +prevents a complete-record result. Error output must not be used as a vehicle +for raw input, secrets or receipt contents. + +## Results and acceptance + +The report separates `tools`, `notificationReceipt` and `autonomousReply` +layers, and evaluates setup, round trip, exact retry, duplicate-delivery +behavior, cancellation, restart, isolation and disclosure. A layer result does +not satisfy the other layers. +In particular, HTTP acceptance without an independently observed run/read/reply +may support notification receipt while autonomy remains unverified. + +| Requirement | Required evidence before the gate can pass | +| --- | --- | +| REQ-001 | Two independent owner consents, eligible actual hosts, distinct identities, active subscriptions and exact deployment/profile | +| REQ-002 | B idle to new event-triggered run/read/reply, then A idle to corresponding new return run/read/reply | +| REQ-003 | Same committed event/message/table/subscription/run associations, ordered transcript and exact-key retry receipt | +| REQ-004 | Pending-work cancellation with no forbidden new authorization; real storage-preserving restart, fresh host path and terminal control | +| REQ-005 | Third-guest denials for private reads and both subscriptions; independently bounded synthetic-only disclosure | +| REQ-006 | Exact-target layered record, restrictions and observed duplicate behavior; explicit blocked/failed/not-run cases | +| REQ-007 | Executable collection/evaluation plus inspected actual positive and boundary receipts, separate from deterministic regressions | + +A structurally complete report establishes checked correspondence, not the +authenticity of externally supplied observations. An independent reviewer must +inspect the retained redacted sources and the declared observation limits. +Synthetic-validator fixtures never satisfy the real-host completion gate. +Do not convert a manual fallback, scheduling/polling, a scripted host, receiver +HTTP 2xx, a model-written statement or another owner's historical tools test +into autonomous success. Passing this offline command is not deployment +approval, a public compatibility advertisement or permission to clear +restore quarantine. + +### Deterministic validation is a separate result + +Run the existing repository gates plus the focused validator tests before an +approved live attempt: + +```sh +npm run typecheck +npm run lint +npx vitest run test/unit/autonomous-evidence.test.ts +node --test scripts/autonomous-pilot.test.mjs +npm test +npm run test:integration +npm run test:restore-runtime +npm run test:e2e +``` + +Existing `events-subscriptions`, `outbox-dispatch`, `outbox-ledger`, +`public-table-tools`, `table-lifecycle` and restore runtime tests exercise +real local Worker/DO boundaries with controlled clients/receivers. Their +success is server regression evidence. It is not execution evidence for +ChatGPT, another named host, a second owner or the deployed candidate. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 7022cd23..ef18489b 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -82,3 +82,15 @@ Every stage should leave: an implemented production path; focused regression tes Before a public release, verify privacy and retention text, rate/admission controls, dependency versions, callback policy, production origin, key rotation procedure, and emergency switches. Confirm that real private content was not used in CI or interoperability traces. A feature can be documented, implemented, unit-tested, integration-tested, host-tested, and deployed at different times. Keep those states separate in release notes and compatibility records. + +## Stage Y — reproducible autonomous-host evidence (#24) + +Use the [two-owner idle-host pilot](compatibility/autonomous-pilot.md) and +`probe:autonomous` offline collector/evaluator for exact deployment/profile +observations. The implementation supplies strict joins and deterministic +negative regressions; the checked-in template remains not-run. Completion +requires independently approved actual owners/hosts, both idle-triggered turns, +exact-key retry, cancellation, retained-state restart and third-guest isolation. +The paused manual release and one-owner tools interoperability do not complete +this stage. Missing account eligibility, observable host-run causality or an +approved private Events activation remain explicit blockers. diff --git a/docs/notifications.md b/docs/notifications.md index aaf03a39..c312aa4f 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -962,3 +962,14 @@ without proving #19's unresolved same-connection DNS-rebinding property or the real idle-agent pilot in #24. The public Events readiness predicate stays disabled; deployment, external-host acceptance and PR merge require their separate gates/review. + +## Issue #24 actual idle-host pilot + +The [two-owner autonomous pilot procedure](compatibility/autonomous-pilot.md) +provides a redacted evidence schema, offline collector/evaluator and explicit +same-event/run/read/reply joins. Its template is **not-run**, and all actual +notification/autonomous-host results remain **blocked/unverified**. CI fixtures +exercise the evidence gate only. The previously accepted #23 tools record and +#50 paused manual release supply no event-triggered host-run proof. Concrete +private activation, two independent owner consents and supported host-run +observations are still required; public Events remain disabled. diff --git a/docs/references.md b/docs/references.md index 38d61cbb..cae4fdf8 100644 --- a/docs/references.md +++ b/docs/references.md @@ -148,3 +148,14 @@ Resolve these through implementation evidence and deployment decisions. Unknown - [Local Worker security](https://developers.cloudflare.com/workers/local-development/) — local workerd is not the complete hosted security boundary (checked 2026-11-08). - [Workers known issues](https://developers.cloudflare.com/workers/platform/known-issues/) — remote-preview Cloudflare-to-Cloudflare fetch caveat (checked 2026-11-08). - [Global fetch strictly public](https://developers.cloudflare.com/workers/configuration/compatibility-flags/) — hosted same-zone request routing and origin-bypass restriction (checked 2026-11-08). + +### AGENT-TABLE-AUTONOMOUS-PILOT + +[Issue #24](https://github.com/kitamura-tetsuo/agent-table/issues/24) defines the +actual two-owner idle-host acceptance contract. The +[reproducible pilot procedure](compatibility/autonomous-pilot.md) and offline +`probe:autonomous` collector/evaluator keep tools, notification receipt and +autonomous reply results separate. No passing live record accompanies this +implementation. Schema consistency and matching receipt digests do not +independently authenticate external observations; real supported host audit +and committed server/transcript evidence remain necessary. diff --git a/docs/release-preflight.md b/docs/release-preflight.md index 66e015de..2df4fd81 100644 --- a/docs/release-preflight.md +++ b/docs/release-preflight.md @@ -79,3 +79,21 @@ The command requires the operator-supplied counterpart configuration and checks Workerd oracle are still synthetic and do not replace those operator gates. Local validation: `node --test scripts/release-preflight.test.mjs`. CI runs this automatically via `node --test scripts/*.test.mjs` and checks the new sources with Biome. No deploy command belongs in PR CI. + +## Issue #24 autonomous evidence gate + +The [two-owner idle-host pilot](compatibility/autonomous-pilot.md) supplies an +offline `probe:autonomous` collector/evaluator and a not-run evidence template. +It requires the continuous real-host round trip, exact mutation-key retry, +pending-work cancellation, retained-state restart and third-guest isolation +before its complete-record result. This output checks correspondence and +receipt integrity; independent review must establish the observations' origin. +It is not release authorization and does not enable any capability switch. + +The [2026-11-12 #50 acceptance](https://github.com/kitamura-tetsuo/agent-table/issues/50#issuecomment-6825493911) +records the protected **paused manual** production release. It supersedes the +earlier production-not-verified status above for that exact scoped release, +but does not authorize live/Events/autonomous operation or restore clearance. +The authenticated gate remains manual-only; #22/#51's independent reconciliation +work and #24's actual host observations remain separate. The pilot must not use +a manual approval, an SDK test, or another build's record to claim those gates. diff --git a/package.json b/package.json index bdcbd476..eb73b3d3 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test:restore-runtime": "vitest run --config vitest.restore.config.ts", "probe:subscriptions:sdk": "node scripts/mcp-subscriptions-sdk.test.mjs", "probe:subscriptions:table": "node scripts/inbox-streams-workerd.test.mjs", - "probe:subscriptions:two-worker": "node scripts/two-worker-subscriptions.mjs" + "probe:subscriptions:two-worker": "node scripts/two-worker-subscriptions.mjs", + "probe:autonomous": "node scripts/autonomous-pilot.mjs" }, "dependencies": { "@cloudflare/workers-oauth-provider": "1.2.4", diff --git a/scripts/autonomous-pilot.mjs b/scripts/autonomous-pilot.mjs new file mode 100644 index 00000000..8088d225 --- /dev/null +++ b/scripts/autonomous-pilot.mjs @@ -0,0 +1,300 @@ +import { createHash } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + openSync, + readSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + autonomousEvidenceSchema, + evaluateAutonomousEvidence, +} from "../test/e2e/host/autonomous-evidence.ts"; + +const MAX_BYTES = 1_048_576; +const MAX_FRAGMENTS = 16; +const RECEIPT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,95}$/; +const TEMPLATE = new URL( + "../test/e2e/host/autonomous-evidence.template.json", + import.meta.url, +); +const DISCLAIMER = + "Metadata correlation and local receipt integrity only. An independent reviewer must inspect the real host/server observations; hashes do not authenticate their origin."; + +function invalid(message) { + throw new Error(message); +} + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Bound reads and refuse symlinks, devices and directories without logging input. */ +function readBounded(path) { + const fd = openSync( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > MAX_BYTES) { + invalid("Input must be a bounded regular file."); + } + const bytes = Buffer.alloc(MAX_BYTES + 1); + let length = 0; + while (length < bytes.length) { + const count = readSync(fd, bytes, length, bytes.length - length, null); + if (count === 0) break; + length += count; + } + if (length > MAX_BYTES) invalid("Input is too large."); + return bytes.subarray(0, length); + } finally { + closeSync(fd); + } +} + +function readJson(path) { + try { + return JSON.parse(readBounded(path).toString("utf8")); + } catch { + // JSON parser and filesystem error text can contain secrets or private paths. + invalid("Cannot read bounded JSON input."); + } +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); +} + +/** Join disjoint redacted capture sections; never overwrite a prior observation. */ +export function collectEvidenceFragments(fragments) { + if ( + !Array.isArray(fragments) || + !fragments.length || + fragments.length > MAX_FRAGMENTS + ) { + invalid("Provide between one and sixteen evidence fragments."); + } + const allowed = new Set( + Object.keys(autonomousEvidenceSchema.shape).filter( + (key) => key !== "schemaVersion" && key !== "pilotId", + ), + ); + const joined = Object.create(null); + const assigned = new Set(); + const observationIds = new Set(); + let pilotId; + for (const fragment of fragments) { + if ( + !object(fragment) || + Object.keys(fragment).length !== 3 || + fragment.schemaVersion !== 1 || + typeof fragment.pilotId !== "string" || + !object(fragment.sections) || + Object.keys(fragment).some( + (key) => !["schemaVersion", "pilotId", "sections"].includes(key), + ) + ) { + invalid("Invalid evidence fragment envelope."); + } + if (pilotId !== undefined && pilotId !== fragment.pilotId) { + invalid("Evidence fragments belong to different pilots."); + } + pilotId = fragment.pilotId; + for (const [key, value] of Object.entries(fragment.sections)) { + if (!allowed.has(key)) invalid("Unknown evidence section."); + if (key === "observations") { + if (!Array.isArray(value)) invalid("Invalid observation registry."); + for (const observation of value) { + if ( + !object(observation) || + typeof observation.id !== "string" || + observationIds.has(observation.id) + ) { + invalid("Duplicate or invalid observation reference."); + } + observationIds.add(observation.id); + } + joined.observations = [...(joined.observations ?? []), ...value]; + } else { + if (assigned.has(key)) + invalid("An evidence section was supplied more than once."); + joined[key] = value; + } + assigned.add(key); + } + } + const parsed = autonomousEvidenceSchema.safeParse({ + schemaVersion: 1, + pilotId, + ...joined, + }); + if (!parsed.success) + invalid("Collected evidence does not match the safe schema."); + return parsed.data; +} + +/** Receipt bytes remain local: reports contain only integrity results. */ +export function checkObservationReceipts(record, receiptDirectory) { + if (!receiptDirectory) + return { + status: "not-checked", + checked: 0, + missing: ["Local redacted receipt hashes were not checked."], + }; + const missing = []; + let checked = 0; + for (const [index, observation] of record.observations.entries()) { + if (!RECEIPT_ID.test(observation.id) || observation.id.includes("..")) { + missing.push(`Receipt ${index + 1}: unsafe observation reference.`); + continue; + } + try { + const bytes = readBounded( + resolve(receiptDirectory, `${observation.id}.json`), + ); + // The collector never prints, republishes, or interprets retained receipt text. + const digest = createHash("sha256").update(bytes).digest("hex"); + if (digest !== observation.sha256) { + missing.push(`Receipt ${index + 1}: digest mismatch.`); + } else { + checked += 1; + } + } catch { + missing.push(`Receipt ${index + 1}: unavailable or unsafe file.`); + } + } + return { status: missing.length ? "failed" : "checked", checked, missing }; +} + +export function buildPilotReport(input, receiptDirectory) { + const evaluation = evaluateAutonomousEvidence(input); + const parsed = autonomousEvidenceSchema.safeParse(input); + const receipts = parsed.success + ? checkObservationReceipts(parsed.data, receiptDirectory) + : { status: "not-checked", checked: 0, missing: [] }; + return { + schemaVersion: 1, + target: parsed.success + ? { + pilotId: parsed.data.pilotId, + evidenceKind: parsed.data.evidenceKind, + observedAt: parsed.data.observedAt, + tableId: parsed.data.tableId, + deployment: parsed.data.deployment, + participants: parsed.data.participants.map((person) => ({ + role: person.role, + ownerRef: person.ownerRef, + accountRef: person.accountRef, + workspaceRef: person.workspaceRef, + host: person.host, + })), + recordSha256: createHash("sha256") + .update(JSON.stringify(parsed.data)) + .digest("hex"), + } + : null, + ...evaluation, + completeRecord: evaluation.completeRecord && receipts.status === "checked", + missing: [...evaluation.missing, ...receipts.missing], + receiptIntegrity: { status: receipts.status, checked: receipts.checked }, + interpretation: DISCLAIMER, + }; +} + +function argumentsFor(argv) { + const [command, ...values] = argv; + if (!["init", "collect", "evaluate"].includes(command)) + invalid("Expected init, collect or evaluate."); + const options = { command, fragments: [] }; + for (let index = 0; index < values.length; index += 2) { + const option = values[index]; + const value = values[index + 1]; + if (!value || value.startsWith("--")) + invalid("Every option requires a value."); + if (option === "--fragment") { + options.fragments.push(value); + continue; + } + if (!["--input", "--output", "--report", "--receipt-dir"].includes(option)) + invalid("Unknown option."); + if (options[option] !== undefined) + invalid("An option was supplied more than once."); + options[option] = value; + } + if (!options["--output"]) invalid("An output path is required."); + if ( + command === "init" && + (options.fragments.length || + options["--input"] || + options["--report"] || + options["--receipt-dir"]) + ) + invalid("Init accepts only an output path."); + if ( + command === "evaluate" && + (!options["--input"] || options.fragments.length || options["--report"]) + ) + invalid("Evaluate requires one input and one output."); + if ( + command === "collect" && + (!options.fragments.length || + options["--input"] || + !options["--report"] || + !options["--receipt-dir"]) + ) + invalid( + "Collect requires fragments, receipt directory, evidence output and report output.", + ); + return options; +} + +export function main(argv) { + try { + const options = argumentsFor(argv); + if (options.command === "init") { + const template = autonomousEvidenceSchema.parse(readJson(TEMPLATE)); + writeJson(options["--output"], template); + process.stdout.write( + `${JSON.stringify({ initialized: true, livePilot: "not-run" })}\n`, + ); + return 0; + } + const evidence = + options.command === "collect" + ? collectEvidenceFragments(options.fragments.map(readJson)) + : readJson(options["--input"]); + const report = buildPilotReport(evidence, options["--receipt-dir"]); + if (options.command === "collect") { + if (resolve(options["--output"]) === resolve(options["--report"])) + invalid("Evidence and report outputs must be distinct."); + writeJson(options["--output"], evidence); + writeJson(options["--report"], report); + } else { + writeJson(options["--output"], report); + } + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report.structurallyValid ? (report.completeRecord ? 0 : 1) : 2; + } catch { + // Do not forward input values, OS paths, callback data or provider error text. + process.stdout.write( + `${JSON.stringify({ error: "Pilot input/output rejected. Check safe schema, bounded regular files, CLI options and unused output paths." })}\n`, + ); + return 2; + } +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/autonomous-pilot.test.mjs b/scripts/autonomous-pilot.test.mjs new file mode 100644 index 00000000..e681070f --- /dev/null +++ b/scripts/autonomous-pilot.test.mjs @@ -0,0 +1,278 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + buildPilotReport, + checkObservationReceipts, + collectEvidenceFragments, +} from "./autonomous-pilot.mjs"; + +const script = fileURLToPath( + new URL("./autonomous-pilot.mjs", import.meta.url), +); +const templatePath = new URL( + "../test/e2e/host/autonomous-evidence.template.json", + import.meta.url, +); +const directories = []; +const freshTemplate = () => JSON.parse(readFileSync(templatePath, "utf8")); +const workspace = () => { + const dir = mkdtempSync(join(tmpdir(), "agent-table-pilot-")); + directories.push(dir); + return dir; +}; +const run = (...args) => + spawnSync(process.execPath, [script, ...args], { encoding: "utf8" }); +const fragment = (sections, pilotId = freshTemplate().pilotId) => ({ + schemaVersion: 1, + pilotId, + sections, +}); +const sectionsOf = (record) => + Object.fromEntries( + Object.entries(record).filter( + ([key]) => !["schemaVersion", "pilotId"].includes(key), + ), + ); + +afterEach(() => { + for (const dir of directories.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); + +test("initialization writes a private not-run record and refuses to overwrite it", () => { + const output = join(workspace(), "candidate.json"); + const created = run("init", "--output", output); + assert.equal(created.status, 0); + const original = readFileSync(output, "utf8"); + assert.deepEqual(JSON.parse(original), freshTemplate()); + assert.equal(statSync(output).mode & 0o777, 0o600); + assert.equal(run("init", "--output", output).status, 2); + assert.equal(readFileSync(output, "utf8"), original); +}); + +test("CLI reports the unrun pilot as incomplete and emits the same JSON to file and stdout", () => { + const output = join(workspace(), "report.json"); + const result = run( + "evaluate", + "--input", + fileURLToPath(templatePath), + "--output", + output, + ); + assert.equal(result.status, 1); + const report = JSON.parse(result.stdout); + assert.equal(report.structurallyValid, true); + assert.equal(report.completeRecord, false); + assert.equal(report.receiptIntegrity.status, "not-checked"); + assert.equal(report.target.pilotId, freshTemplate().pilotId); + assert.deepEqual( + report.target.participants[0].host, + freshTemplate().participants[0].host, + ); + assert.match(report.target.recordSha256, /^[a-f0-9]{64}$/); + assert.deepEqual(JSON.parse(readFileSync(output, "utf8")), report); + assert.match(report.interpretation, /hashes do not authenticate/); +}); + +test("invalid evidence cannot echo secret-bearing unknown keys or values", () => { + const dir = workspace(); + const input = join(dir, "invalid.json"); + const output = join(dir, "report.json"); + const secret = "sensitive-callback-and-bearer-sentinel"; + const record = freshTemplate(); + record[secret] = { callback: `https://example.invalid/${secret}` }; + writeFileSync(input, JSON.stringify(record)); + const result = run("evaluate", "--input", input, "--output", output); + assert.equal(result.status, 2); + assert.equal(result.stdout.includes(secret), false); + assert.equal(result.stderr.includes(secret), false); + assert.equal(readFileSync(output, "utf8").includes(secret), false); + assert.equal(JSON.parse(result.stdout).completeRecord, false); +}); + +test("malformed or oversized input fails without leaking parser text or private paths", () => { + const dir = workspace(); + const input = join(dir, "private-secret-sentinel.json"); + const output = join(dir, "report.json"); + for (const bytes of ['{"secret-value-sentinel":', "x".repeat(1_048_577)]) { + writeFileSync(input, bytes); + const result = run("evaluate", "--input", input, "--output", output); + assert.equal(result.status, 2); + assert.equal(result.stdout.includes("sentinel"), false); + assert.equal(result.stderr.includes("sentinel"), false); + } +}); + +test("collector joins separate capture sections without replacing previous facts", () => { + const record = freshTemplate(); + const { observations, ...remaining } = sectionsOf(record); + const entries = Object.entries(remaining); + const split = Math.ceil(entries.length / 2); + const captured = [ + fragment(Object.fromEntries(entries.slice(0, split))), + fragment(Object.fromEntries(entries.slice(split))), + fragment({ observations }), + ]; + assert.deepEqual(collectEvidenceFragments(captured), record); + assert.throws( + () => + collectEvidenceFragments([ + ...captured, + fragment({ evidenceKind: "real-host" }), + ]), + /more than once/, + ); + assert.throws( + () => + collectEvidenceFragments([ + captured[0], + { ...captured[1], pilotId: "different-pilot" }, + ]), + /different pilots/, + ); +}); + +test("collector rejects unknown fields, duplicate receipts and missing sections", () => { + const sections = sectionsOf(freshTemplate()); + assert.throws( + () => + collectEvidenceFragments([ + fragment({ ...sections, accessToken: "do-not-publish" }), + ]), + /Unknown evidence section/, + ); + assert.throws( + () => + collectEvidenceFragments([ + { ...fragment(sections), secret: "do-not-publish" }, + ]), + /envelope/, + ); + assert.throws( + () => collectEvidenceFragments([fragment({ observations: [] })]), + /safe schema/, + ); + const observation = { id: "receipt-0001" }; + assert.throws( + () => + collectEvidenceFragments([ + fragment({ ...sections, observations: [observation] }), + fragment({ observations: [observation] }), + ]), + /Duplicate or invalid/, + ); +}); + +test("retained receipt hashing refuses mutation, missing files, symlinks and traversal", () => { + const dir = workspace(); + const bytes = '{"redacted":"local independent observation"}\n'; + const digest = createHash("sha256").update(bytes).digest("hex"); + writeFileSync(join(dir, "receipt-0001.json"), bytes); + const record = { observations: [{ id: "receipt-0001", sha256: digest }] }; + assert.deepEqual(checkObservationReceipts(record, dir), { + status: "checked", + checked: 1, + missing: [], + }); + writeFileSync(join(dir, "receipt-0001.json"), "changed"); + assert.equal(checkObservationReceipts(record, dir).status, "failed"); + record.observations[0].id = "receipt-absent"; + assert.equal(checkObservationReceipts(record, dir).status, "failed"); + symlinkSync(join(dir, "receipt-0001.json"), join(dir, "receipt-link.json")); + record.observations[0].id = "receipt-link"; + assert.equal(checkObservationReceipts(record, dir).status, "failed"); + record.observations[0].id = "../private-secret"; + const traversal = checkObservationReceipts(record, dir); + assert.equal(traversal.status, "failed"); + assert.equal(JSON.stringify(traversal).includes("private-secret"), false); +}); + +test("an empty receipt directory cannot complete an unrun pilot", () => { + const report = buildPilotReport(freshTemplate(), workspace()); + assert.equal(report.receiptIntegrity.status, "checked"); + assert.equal(report.completeRecord, false); + assert.ok(report.missing.length > 0); +}); + +test("nonregular input and receipt FIFOs fail without waiting for a writer", { + skip: process.platform === "win32", +}, () => { + const dir = workspace(); + const fifo = join(dir, "receipt-fifo.json"); + assert.equal(spawnSync("mkfifo", [fifo]).status, 0); + const result = spawnSync( + process.execPath, + [script, "evaluate", "--input", fifo, "--output", join(dir, "report.json")], + { encoding: "utf8", timeout: 3000 }, + ); + assert.equal(result.error, undefined); + assert.equal(result.status, 2); + const record = { + observations: [{ id: "receipt-fifo", sha256: "a".repeat(64) }], + }; + assert.equal(checkObservationReceipts(record, dir).status, "failed"); +}); + +test("collect CLI persists safe candidate evidence and a blocked report", () => { + const dir = workspace(); + const input = join(dir, "fragment.json"); + const output = join(dir, "evidence.json"); + const report = join(dir, "report.json"); + writeFileSync(input, JSON.stringify(fragment(sectionsOf(freshTemplate())))); + const result = run( + "collect", + "--fragment", + input, + "--receipt-dir", + dir, + "--output", + output, + "--report", + report, + ); + assert.equal(result.status, 1); + assert.deepEqual(JSON.parse(readFileSync(output, "utf8")), freshTemplate()); + assert.deepEqual( + JSON.parse(readFileSync(report, "utf8")), + JSON.parse(result.stdout), + ); + assert.equal(statSync(output).mode & 0o777, 0o600); + assert.equal(JSON.parse(result.stdout).completeRecord, false); +}); + +test("CLI does not overwrite an existing report or accept ambiguous options", () => { + const dir = workspace(); + const output = join(dir, "report.json"); + writeFileSync(output, "keep this original report"); + assert.equal( + run("evaluate", "--input", fileURLToPath(templatePath), "--output", output) + .status, + 2, + ); + assert.equal(readFileSync(output, "utf8"), "keep this original report"); + assert.equal( + run( + "evaluate", + "--input", + fileURLToPath(templatePath), + "--output", + output, + "--output", + join(dir, "other.json"), + ).status, + 2, + ); +}); diff --git a/test/e2e/host/autonomous-evidence.template.json b/test/e2e/host/autonomous-evidence.template.json new file mode 100644 index 00000000..6243046b --- /dev/null +++ b/test/e2e/host/autonomous-evidence.template.json @@ -0,0 +1,100 @@ +{ + "schemaVersion": 1, + "pilotId": "synthetic_not_run", + "evidenceKind": "not-run", + "observedAt": null, + "tableId": null, + "deployment": { + "buildSha": null, + "configSha256": null, + "endpointRef": null, + "approvalRef": null, + "configurationRef": null, + "isolated": null, + "publicAdvertisement": null, + "protocol": null, + "profile": null + }, + "participants": [ + { + "role": "A", + "ownerRef": null, + "accountRef": null, + "workspaceRef": null, + "guestId": null, + "connectionId": null, + "membershipId": null, + "subscriptionId": null, + "host": { + "product": "NOT_SELECTED", + "surface": "NOT_SELECTED", + "exposedBuild": "unknown", + "eligibility": "unknown", + "origin": "not-tested", + "runObservation": "unsupported" + }, + "oauthEvidenceRef": null, + "subscription": { + "tableId": null, + "activeAt": null, + "observationRef": null + }, + "consent": { + "approvedAt": null, + "observationRef": null, + "instructionsSha256": null, + "maxReplies": null, + "syntheticOnly": null, + "readBeforeReply": null, + "reuseMutationKey": null, + "stopOnTerminal": null, + "suppressAcknowledgmentLoops": null, + "askBeforeOutOfScope": null + } + }, + { + "role": "B", + "ownerRef": null, + "accountRef": null, + "workspaceRef": null, + "guestId": null, + "connectionId": null, + "membershipId": null, + "subscriptionId": null, + "host": { + "product": "NOT_SELECTED", + "surface": "NOT_SELECTED", + "exposedBuild": "unknown", + "eligibility": "unknown", + "origin": "not-tested", + "runObservation": "unsupported" + }, + "oauthEvidenceRef": null, + "subscription": { + "tableId": null, + "activeAt": null, + "observationRef": null + }, + "consent": { + "approvedAt": null, + "observationRef": null, + "instructionsSha256": null, + "maxReplies": null, + "syntheticOnly": null, + "readBeforeReply": null, + "reuseMutationKey": null, + "stopOnTerminal": null, + "suppressAcknowledgmentLoops": null, + "askBeforeOutOfScope": null + } + } + ], + "turns": [], + "retry": null, + "duplicateDelivery": null, + "cancellation": null, + "restart": null, + "isolation": [], + "disclosure": null, + "observations": [] +} diff --git a/test/e2e/host/autonomous-evidence.ts b/test/e2e/host/autonomous-evidence.ts new file mode 100644 index 00000000..1c14374b --- /dev/null +++ b/test/e2e/host/autonomous-evidence.ts @@ -0,0 +1,1415 @@ +import { z } from "zod"; + +// Only redacted identifiers and metadata belong here. Receipts are retained +// separately; neither a well-formed record nor its digest authenticates them. +const ref = z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{7,95}$/) + .refine((value) => !value.includes("..")); +const digest = z.string().regex(/^[a-f0-9]{64}$/); +const buildSha = z.string().regex(/^[a-f0-9]{40}$/); +const time = z.string().datetime({ offset: true }); +const label = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9 ._()+-]{0,99}$/); +const role = z.enum(["A", "B"]); + +export const autonomousObservationSchema = z.strictObject({ + id: ref, + source: z.enum([ + "owner-consent", + "host-audit", + "host-tool", + "server", + "operator", + "browser", + ]), + capturedAt: time, + sha256: digest, +}); + +const participant = z.strictObject({ + role, + ownerRef: ref.nullable(), + accountRef: ref.nullable(), + workspaceRef: ref.nullable(), + guestId: ref.nullable(), + connectionId: ref.nullable(), + membershipId: ref.nullable(), + subscriptionId: ref.nullable(), + host: z.strictObject({ + product: label, + surface: label, + exposedBuild: label, + eligibility: z.enum(["unknown", "eligible", "ineligible"]), + origin: z.enum(["not-tested", "actual-host", "sdk-only", "simulated"]), + runObservation: z.enum(["unsupported", "host-ui", "provider-audit"]), + }), + oauthEvidenceRef: ref.nullable(), + subscription: z.strictObject({ + tableId: ref.nullable(), + activeAt: time.nullable(), + observationRef: ref.nullable(), + }), + consent: z.strictObject({ + approvedAt: time.nullable(), + observationRef: ref.nullable(), + instructionsSha256: digest.nullable(), + maxReplies: z.number().int().min(1).max(12).nullable(), + syntheticOnly: z.boolean().nullable(), + readBeforeReply: z.boolean().nullable(), + reuseMutationKey: z.boolean().nullable(), + stopOnTerminal: z.boolean().nullable(), + suppressAcknowledgmentLoops: z.boolean().nullable(), + askBeforeOutOfScope: z.boolean().nullable(), + }), +}); + +const committedMessage = z.strictObject({ + tableId: ref, + messageId: ref, + seq: z.number().int().positive(), + senderMembershipId: ref, + committedAt: time, + mutationKeySha256: digest, + observationRef: ref, + hostObservationRef: ref, +}); +const delivery = z.strictObject({ + attemptId: ref, + eventId: ref, + subscriptionId: ref, + authorizedAt: time, + dispatchedAt: time, + acceptedAt: time.nullable(), + statusCode: z.number().int().min(100).max(599).nullable(), + observationRef: ref, +}); +const turn = z.strictObject({ + id: ref, + scenario: z.enum(["roundtrip", "restart"]), + sender: role, + recipient: role, + post: committedMessage, + event: z.strictObject({ + tableId: ref, + eventId: ref, + messageId: ref, + messageSeq: z.number().int().positive(), + senderMembershipId: ref, + recipientMembershipId: ref, + subscriptionId: ref, + committedAt: time, + observationRef: ref, + }), + deliveries: z.array(delivery).max(20), + idle: z + .strictObject({ + observedAt: time, + activeRuns: z.number().int().nonnegative(), + observationRef: ref, + }) + .nullable(), + run: z + .strictObject({ + runId: ref, + triggeringEventId: ref, + deliveryAttemptId: ref, + trigger: z.enum([ + "peer-event", + "manual", + "polling", + "scripted", + "unknown", + ]), + startedAt: time, + observationRef: ref, + }) + .nullable(), + read: z + .strictObject({ + runId: ref, + eventId: ref, + tableId: ref, + membershipId: ref, + connectionId: ref, + afterSeq: z.number().int().nonnegative(), + throughSeq: z.number().int().nonnegative(), + observedMessageIds: z.array(ref).max(200), + readAt: time, + hostObservationRef: ref, + serverObservationRef: ref, + }) + .nullable(), + reply: committedMessage + .extend({ + runId: ref, + replyToMessageId: ref, + }) + .nullable(), +}); + +const snapshot = z.strictObject({ + storageIdentity: ref, + buildSha, + configSha256: digest, + retryStateSha256: digest, + subscriptionIds: z.tuple([ref, ref]), + observedAt: time, + observationRef: ref, +}); +const cancellationAttempt = z.strictObject({ + attemptId: ref, + eventId: ref, + authorizedAt: time, + dispatchedAt: time.nullable(), + completedAt: time.nullable(), +}); + +export const autonomousEvidenceSchema = z.strictObject({ + schemaVersion: z.literal(1), + pilotId: ref, + evidenceKind: z.enum(["not-run", "real-host", "synthetic-validator"]), + observedAt: time.nullable(), + tableId: ref.nullable(), + deployment: z.strictObject({ + buildSha: buildSha.nullable(), + configSha256: digest.nullable(), + endpointRef: ref.nullable(), + approvalRef: ref.nullable(), + configurationRef: ref.nullable(), + isolated: z.boolean().nullable(), + publicAdvertisement: z.boolean().nullable(), + protocol: label.nullable(), + profile: label.nullable(), + }), + participants: z.tuple([participant, participant]), + turns: z.array(turn).max(12), + retry: z + .strictObject({ + turnId: ref, + originalMutationKeySha256: digest, + retryMutationKeySha256: digest, + originalPayloadSha256: digest, + retryPayloadSha256: digest, + originalMessageId: ref, + retryMessageId: ref, + originalSeq: z.number().int().positive(), + retrySeq: z.number().int().positive(), + beforeCount: z.number().int().positive(), + afterCount: z.number().int().positive(), + retriedAt: time, + observationRef: ref, + }) + .nullable(), + duplicateDelivery: z + .strictObject({ + turnId: ref, + eventId: ref, + acceptedAttemptIds: z.array(ref).min(1).max(20), + observedRunIds: z.array(ref).min(1).max(20), + acceptedReplyMessageIds: z.array(ref).min(1).max(20), + behavior: z.enum(["not-observed", "no-additional-run", "additional-run"]), + through: time, + hostObservationRef: ref, + serverObservationRef: ref, + }) + .nullable(), + cancellation: z + .strictObject({ + tableId: ref, + action: z.enum(["close", "unsubscribe"]), + subscriptionId: ref, + pendingEventId: ref, + pendingAt: time, + decisionAt: time, + observationRef: ref, + attempts: z.array(cancellationAttempt).max(20), + coverageFrom: time, + coverageThrough: time, + coverageObservationRef: ref, + postDecisionProbeAt: time, + refusalObservationRef: ref, + }) + .nullable(), + restart: z + .strictObject({ + mode: z.enum(["retained-storage", "fresh-storage", "unknown"]), + tableId: ref, + startedAt: time, + readyAt: time, + operationRef: ref, + before: snapshot, + after: snapshot, + newTurnId: ref, + terminalTableId: ref, + terminalSubscriptionId: ref, + terminalStateBefore: z.enum([ + "closed", + "deleted", + "expired", + "unsubscribed", + ]), + terminalStateAfter: z.enum([ + "closed", + "deleted", + "expired", + "unsubscribed", + ]), + terminalCheckAt: time, + terminalDispatchCount: z.number().int().nonnegative(), + terminalObservationRef: ref, + }) + .nullable(), + isolation: z + .array( + z.strictObject({ + action: z.enum([ + "metadata", + "transcript", + "inbox", + "renew-subscription", + "unsubscribe", + ]), + outsiderGuestId: ref, + outsiderConnectionId: ref, + tableId: ref, + targetMembershipId: ref, + targetSubscriptionId: ref, + observedAt: time, + outcome: z.enum(["denied", "allowed", "unknown"]), + privateDataReturned: z.boolean(), + subscriptionChanged: z.boolean(), + clientObservationRef: ref, + serverObservationRef: ref, + }), + ) + .max(20), + disclosure: z + .strictObject({ + syntheticPayloadsSha256: digest, + postedPayloadsSha256: digest, + scope: z.enum(["synthetic-only", "unknown", "other-content"]), + ownerReviewRefs: z.tuple([ref, ref]), + transcriptObservationRef: ref, + }) + .nullable(), + observations: z.array(autonomousObservationSchema).max(250), +}); + +export type AutonomousEvidence = z.infer; +export type AutonomousObservation = z.infer; +export type EvidenceStatus = "not-run" | "blocked" | "failed" | "passed"; +export const AUTONOMOUS_SCENARIOS = [ + "setup", + "roundTrip", + "exactRetry", + "duplicateDelivery", + "cancellation", + "restart", + "isolation", + "disclosure", +] as const; +type Scenario = (typeof AUTONOMOUS_SCENARIOS)[number]; +export interface AutonomousEvidenceResult { + structurallyValid: boolean; + completeRecord: boolean; + missing: string[]; + layers: { + tools: EvidenceStatus; + notificationReceipt: EvidenceStatus; + autonomousReply: EvidenceStatus; + }; + scenarios: Record; +} + +const milliseconds = (value: string | null) => + value === null ? Number.NaN : Date.parse(value); +const isKnown = (value: string) => + !/^(unknown|unavailable|not.selected|not.available|not.exposed)$/i.test( + value, + ); +const knownBuild = (value: string) => + value === "not-exposed-in-tested-host" || + (isKnown(value) && + !/^(unknown|unavailable|not.exposed|not.available)/i.test(value)); +const unique = (values: unknown[]) => new Set(values).size === values.length; + +/** + * Check completeness and causal joins in redacted observations, not their + * authenticity. The CLI separately verifies local receipt digests. A reviewer + * must still inspect independently captured owner, host and server evidence. + */ +export function evaluateAutonomousEvidence( + input: unknown, +): AutonomousEvidenceResult { + const parsed = autonomousEvidenceSchema.safeParse(input); + const scenarios = Object.fromEntries( + AUTONOMOUS_SCENARIOS.map((name) => [name, "not-run"]), + ) as Record; + const layers: AutonomousEvidenceResult["layers"] = { + tools: "not-run", + notificationReceipt: "not-run", + autonomousReply: "not-run", + }; + if (!parsed.success) { + // Even unknown field names can contain secrets; never echo schema input. + return { + structurallyValid: false, + completeRecord: false, + missing: [ + "Invalid autonomous evidence schema; inspect the input locally.", + ], + layers, + scenarios, + }; + } + + const record = parsed.data; + const missing: string[] = []; + const issues = new Map(); + const failed = new Set(); + const observations = new Map( + record.observations.map((item) => [item.id, item]), + ); + const check = ( + scenario: Scenario, + condition: unknown, + detail: string, + observedFailure = false, + ) => { + if (condition) return true; + const message = `${scenario}: ${detail}`; + missing.push(message); + issues.set(scenario, [...(issues.get(scenario) ?? []), message]); + if (observedFailure) failed.add(scenario); + return false; + }; + const receipt = ( + scenario: Scenario, + id: string | null, + source: AutonomousObservation["source"], + detail: string, + occurredAt?: string | null, + ) => { + const observation = id === null ? undefined : observations.get(id); + const valid = check(scenario, observation?.source === source, detail); + if (observation && occurredAt) + check( + scenario, + milliseconds(observation.capturedAt) >= milliseconds(occurredAt), + "receipt capture does not predate its observed fact", + true, + ); + return valid; + }; + const finish = (scenario: Scenario, ran: boolean) => { + scenarios[scenario] = !ran + ? "not-run" + : failed.has(scenario) + ? "failed" + : issues.has(scenario) + ? "blocked" + : "passed"; + }; + + check( + "setup", + unique(record.observations.map((item) => item.id)), + "unique receipt identifiers", + ); + check( + "setup", + unique(record.turns.map((item) => item.id)), + "unique turn identifiers", + ); + check("setup", record.observedAt !== null, "dated pilot observation"); + // Schema validation already restricted timestamp fields and unknown keys. + const observedTimes: number[] = []; + const collectTimes = (value: unknown, key = "") => { + if ( + typeof value === "string" && + /(?:At|From|Through|^through)$/.test(key) + ) { + observedTimes.push(milliseconds(value)); + } else if (Array.isArray(value)) { + for (const entry of value) collectTimes(entry); + } else if (value !== null && typeof value === "object") { + for (const [name, entry] of Object.entries(value)) + collectTimes(entry, name); + } + }; + collectTimes(record); + check( + "setup", + record.observedAt && + observedTimes.every((value) => value <= milliseconds(record.observedAt)), + "report date covers all fact and receipt capture timestamps", + record.observedAt !== null, + ); + check("setup", record.tableId !== null, "one primary private table"); + const deployment = record.deployment; + check( + "setup", + deployment.buildSha && deployment.configSha256 && deployment.endpointRef, + "pinned deployment build, configuration and endpoint reference", + ); + check( + "setup", + deployment.isolated === true && deployment.publicAdvertisement === false, + "approved isolated deployment without public support advertisement", + ); + check( + "setup", + deployment.protocol && + deployment.profile && + isKnown(deployment.protocol) && + isKnown(deployment.profile), + "exact tested protocol and notification profile", + ); + receipt( + "setup", + deployment.approvalRef, + "operator", + "isolated deployment approval receipt", + ); + receipt( + "setup", + deployment.configurationRef, + "operator", + "deployed build/configuration receipt", + ); + const [a, b] = record.participants; + check( + "setup", + a.role === "A" && b.role === "B", + "ordered A and B participant records", + ); + for (const key of [ + "ownerRef", + "accountRef", + "guestId", + "connectionId", + "membershipId", + "subscriptionId", + ] as const) { + check( + "setup", + a[key] && b[key] && a[key] !== b[key], + `two independent participant ${key} values`, + ); + } + check( + "setup", + a.consent.observationRef !== b.consent.observationRef, + "separate owner consent receipts", + ); + for (const person of record.participants) { + check( + "setup", + person.workspaceRef && + person.host.eligibility === "eligible" && + person.host.origin === "actual-host", + "eligible actual-host account/workspace for each owner", + ); + check( + "setup", + isKnown(person.host.product) && + isKnown(person.host.surface) && + knownBuild(person.host.exposedBuild), + "exact host identity or explicit not-exposed-in-tested-host build marker", + ); + check( + "setup", + person.host.runObservation !== "unsupported", + "supported independent host idle/run observation surface", + ); + receipt( + "setup", + person.oauthEvidenceRef, + "host-tool", + "actual-host guest OAuth/tool receipt for each participant", + ); + const subscription = person.subscription; + check( + "setup", + subscription.tableId === record.tableId && subscription.activeAt, + "active subscriptions on the same primary table", + ); + receipt( + "setup", + subscription.observationRef, + "server", + "active subscription authority receipt for each participant", + subscription.activeAt, + ); + const consent = person.consent; + check( + "setup", + consent.approvedAt && consent.instructionsSha256 && consent.maxReplies, + "separate dated bounded standing instructions", + ); + receipt( + "setup", + consent.observationRef, + "owner-consent", + "independent owner approval of standing instructions", + consent.approvedAt, + ); + check( + "setup", + consent.syntheticOnly && + consent.readBeforeReply && + consent.reuseMutationKey && + consent.stopOnTerminal && + consent.suppressAcknowledgmentLoops && + consent.askBeforeOutOfScope, + "synthetic disclosure, read/retry/stop/authority and acknowledgment bounds", + ); + check( + "setup", + milliseconds(consent.approvedAt) <= milliseconds(subscription.activeAt), + "owner consent precedes active subscription", + ); + } + check( + "setup", + unique(record.turns.map((item) => item.event.eventId)), + "distinct committed event identifiers across all turns", + true, + ); + check( + "setup", + unique(record.turns.flatMap((item) => (item.run ? [item.run.runId] : []))), + "distinct new host run identifiers across all turns", + true, + ); + check( + "setup", + unique( + record.turns.flatMap((item) => + item.deliveries.map((entry) => entry.attemptId), + ), + ), + "distinct dispatch attempt identifiers across all turns", + true, + ); + const messages = new Map(); + const sequences = new Map(); + const mutationKeys = new Map(); + let immutableMessagesValid = true; + for (const item of record.turns) { + const scenario = item.scenario === "restart" ? "restart" : "roundTrip"; + for (const message of [item.post, ...(item.reply ? [item.reply] : [])]) { + const immutable = JSON.stringify([ + message.tableId, + message.seq, + message.senderMembershipId, + message.mutationKeySha256, + message.committedAt, + ]); + if ( + messages.has(message.messageId) && + messages.get(message.messageId) !== immutable + ) + immutableMessagesValid = false; + check( + scenario, + !messages.has(message.messageId) || + messages.get(message.messageId) === immutable, + "one immutable table/sequence/sender/key/time for each accepted message", + true, + ); + messages.set(message.messageId, immutable); + const seqKey = `${message.tableId}:${message.seq}`; + if (sequences.has(seqKey) && sequences.get(seqKey) !== message.messageId) + immutableMessagesValid = false; + check( + scenario, + !sequences.has(seqKey) || sequences.get(seqKey) === message.messageId, + "one accepted message identity for each table sequence", + true, + ); + sequences.set(seqKey, message.messageId); + const mutationKey = `${message.tableId}:${message.senderMembershipId}:${message.mutationKeySha256}`; + if ( + mutationKeys.has(mutationKey) && + mutationKeys.get(mutationKey) !== message.messageId + ) + immutableMessagesValid = false; + check( + scenario, + !mutationKeys.has(mutationKey) || + mutationKeys.get(mutationKey) === message.messageId, + "one accepted message per sender mutation key", + true, + ); + mutationKeys.set(mutationKey, message.messageId); + } + } + for (const person of record.participants) { + const replies = new Set( + record.turns + .filter((item) => item.recipient === person.role) + .flatMap((item) => (item.reply ? [item.reply.messageId] : [])), + ); + const duplicatedTurn = record.turns.find( + (item) => item.id === record.duplicateDelivery?.turnId, + ); + if (duplicatedTurn?.recipient === person.role) { + for (const id of record.duplicateDelivery?.acceptedReplyMessageIds ?? []) + replies.add(id); + } + check( + "setup", + person.consent.maxReplies !== null && + replies.size <= person.consent.maxReplies, + "all observed replies including duplicates stay within each owner's approved bound", + person.consent.maxReplies !== null, + ); + } + finish("setup", record.evidenceKind !== "not-run"); + + const validateTurn = ( + item: AutonomousEvidence["turns"][number], + scenario: Scenario, + ) => { + const start = missing.length; + const sender = item.sender === "A" ? a : b; + const recipient = item.recipient === "A" ? a : b; + const { post, event } = item; + check( + scenario, + item.sender !== item.recipient, + "peer membership rather than self-wakeup", + ); + check( + scenario, + post.tableId === record.tableId && event.tableId === post.tableId, + "committed post/event on the primary table", + ); + check( + scenario, + post.senderMembershipId === sender.membershipId && + event.senderMembershipId === sender.membershipId && + event.recipientMembershipId === recipient.membershipId && + event.subscriptionId === recipient.subscriptionId, + "event sender, recipient and active subscription association", + ); + check( + scenario, + event.messageId === post.messageId && + event.messageSeq === post.seq && + milliseconds(event.committedAt) >= milliseconds(post.committedAt), + "same committed event/message and ordered sequence", + ); + check( + scenario, + record.participants.every( + (person) => + milliseconds(person.subscription.activeAt) <= + milliseconds(post.committedAt), + ), + "both subscriptions active before peer post", + ); + receipt( + scenario, + post.observationRef, + "server", + "committed post receipt", + post.committedAt, + ); + receipt( + scenario, + post.hostObservationRef, + "host-tool", + "actual initiating agent post tool receipt with committed message result", + post.committedAt, + ); + receipt( + scenario, + event.observationRef, + "server", + "committed peer event receipt", + event.committedAt, + ); + check( + scenario, + unique(item.deliveries.map((entry) => entry.attemptId)), + "unique delivery attempt identifiers", + ); + const accepted = item.deliveries.filter( + (entry) => + entry.statusCode !== null && + entry.statusCode >= 200 && + entry.statusCode < 300 && + entry.acceptedAt !== null, + ); + check( + scenario, + accepted.length > 0, + "observed successful receiver acceptance", + ); + for (const attempt of item.deliveries) { + check( + scenario, + attempt.eventId === event.eventId && + attempt.subscriptionId === event.subscriptionId, + "delivery/event/subscription association", + ); + check( + scenario, + milliseconds(attempt.authorizedAt) >= milliseconds(event.committedAt) && + milliseconds(attempt.dispatchedAt) >= + milliseconds(attempt.authorizedAt) && + (attempt.acceptedAt === null || + milliseconds(attempt.acceptedAt) >= + milliseconds(attempt.dispatchedAt)), + "ordered delivery authorization, dispatch and response", + ); + receipt( + scenario, + attempt.observationRef, + "server", + "safe server delivery observation", + attempt.acceptedAt ?? attempt.dispatchedAt, + ); + } + const notificationValid = missing.length === start; + const { idle, run, read, reply } = item; + check( + scenario, + idle !== null && run !== null && read !== null && reply !== null, + "independent idle, new run, authenticated read and committed reply observations", + ); + if (!idle || !run || !read || !reply) + return { notificationValid, autonomousValid: false }; + const initiatingAttempt = accepted.find( + (entry) => entry.attemptId === run.deliveryAttemptId, + ); + check( + scenario, + idle.activeRuns === 0 && + milliseconds(idle.observedAt) >= + milliseconds(recipient.subscription.activeAt) && + initiatingAttempt && + milliseconds(idle.observedAt) <= milliseconds(post.committedAt), + "recipient idle before the initiating post", + true, + ); + check( + scenario, + run.trigger === "peer-event" && + run.triggeringEventId === event.eventId && + initiatingAttempt && + milliseconds(run.startedAt) >= + milliseconds(initiatingAttempt.dispatchedAt), + "new host run caused by this peer event without human or polling substitute", + true, + ); + receipt( + scenario, + idle.observationRef, + "host-audit", + "supported host idle-state receipt", + idle.observedAt, + ); + receipt( + scenario, + run.observationRef, + "host-audit", + "independent host event-to-new-run receipt", + run.startedAt, + ); + check( + scenario, + read.runId === run.runId && + read.eventId === event.eventId && + read.tableId === post.tableId && + read.membershipId === recipient.membershipId && + read.connectionId === recipient.connectionId && + read.afterSeq < post.seq && + read.throughSeq >= post.seq && + read.observedMessageIds.includes(post.messageId) && + milliseconds(read.readAt) >= milliseconds(run.startedAt), + "same-run authenticated read of the triggering committed message", + ); + receipt( + scenario, + read.hostObservationRef, + "host-tool", + "host authenticated-read tool receipt", + read.readAt, + ); + receipt( + scenario, + read.serverObservationRef, + "server", + "server authenticated-read receipt", + read.readAt, + ); + check( + scenario, + reply.runId === run.runId && + reply.tableId === post.tableId && + reply.senderMembershipId === recipient.membershipId && + reply.replyToMessageId === post.messageId && + reply.messageId !== post.messageId && + reply.seq > post.seq && + milliseconds(reply.committedAt) >= milliseconds(read.readAt), + "same-run committed peer reply after authenticated read", + ); + receipt( + scenario, + reply.observationRef, + "server", + "committed reply receipt", + reply.committedAt, + ); + receipt( + scenario, + reply.hostObservationRef, + "host-tool", + "same-run host reply tool receipt with committed message result", + reply.committedAt, + ); + return { notificationValid, autonomousValid: missing.length === start }; + }; + + const roundtrip = record.turns.filter( + (item) => item.scenario === "roundtrip", + ); + check( + "roundTrip", + roundtrip.length === 2, + "exactly two joined round-trip turns", + ); + const roundResults = roundtrip.map((item) => validateTurn(item, "roundTrip")); + const first = roundtrip.find( + (item) => item.sender === "A" && item.recipient === "B", + ); + const back = roundtrip.find( + (item) => item.sender === "B" && item.recipient === "A", + ); + const firstReply = first?.reply; + check( + "roundTrip", + first && + back && + firstReply && + back.post.messageId === firstReply.messageId && + back.post.seq === firstReply.seq && + back.post.senderMembershipId === firstReply.senderMembershipId && + back.post.mutationKeySha256 === firstReply.mutationKeySha256 && + back.post.committedAt === firstReply.committedAt, + "B reply is the exact committed post causing the return A turn", + ); + check( + "roundTrip", + first?.run && + back?.run && + first.run.runId !== back.run.runId && + first.event.eventId !== back.event.eventId, + "distinct event-driven host runs in both directions", + ); + finish("roundTrip", roundtrip.length > 0); + + const retry = record.retry; + check("exactRetry", retry !== null, "observed exact mutation-key retry"); + if (retry) { + const original = record.turns.find( + (item) => item.id === retry.turnId, + )?.post; + check( + "exactRetry", + original && + retry.originalMutationKeySha256 === original.mutationKeySha256 && + retry.retryMutationKeySha256 === retry.originalMutationKeySha256 && + retry.originalPayloadSha256 === retry.retryPayloadSha256, + "unchanged original mutation key and payload", + ); + check( + "exactRetry", + original && + retry.originalMessageId === original.messageId && + retry.retryMessageId === original.messageId && + retry.originalSeq === original.seq && + retry.retrySeq === original.seq, + "the original accepted message/sequence receipt on retry", + ); + check( + "exactRetry", + original && + retry.beforeCount >= original.seq && + retry.beforeCount === retry.afterCount && + milliseconds(retry.retriedAt) >= milliseconds(original.committedAt), + "no additional accepted transcript message on exact retry", + ); + receipt( + "exactRetry", + retry.observationRef, + "server", + "retry and unchanged transcript receipt", + retry.retriedAt, + ); + } + finish("exactRetry", retry !== null); + + const duplicates = record.duplicateDelivery; + check( + "duplicateDelivery", + duplicates !== null, + "actual duplicate-delivery behavior or explicit not-observed record", + ); + if (duplicates) { + const observedTurn = record.turns.find( + (item) => item.id === duplicates.turnId, + ); + const accepted = + observedTurn?.deliveries.filter( + (entry) => + entry.acceptedAt && + entry.statusCode !== null && + entry.statusCode >= 200 && + entry.statusCode < 300, + ) ?? []; + check( + "duplicateDelivery", + observedTurn && + duplicates.eventId === observedTurn.event.eventId && + accepted.length === duplicates.acceptedAttemptIds.length && + accepted.every((item) => + duplicates.acceptedAttemptIds.includes(item.attemptId), + ), + "duplicate observations joined to accepted attempts for the same event", + ); + check( + "duplicateDelivery", + accepted.every( + (item) => + milliseconds(item.acceptedAt) <= milliseconds(duplicates.through), + ), + "duplicate observation window covers every included receiver acceptance", + true, + ); + check( + "duplicateDelivery", + unique(duplicates.acceptedAttemptIds) && + unique(duplicates.observedRunIds) && + unique(duplicates.acceptedReplyMessageIds), + "unique observed duplicate attempt/run/reply identifiers", + ); + check( + "duplicateDelivery", + observedTurn?.run && + observedTurn.reply && + duplicates.observedRunIds.includes(observedTurn.run.runId) && + duplicates.acceptedReplyMessageIds.includes( + observedTurn.reply.messageId, + ) && + milliseconds(duplicates.through) >= + milliseconds(observedTurn.reply.committedAt), + "host run/reply observation window for the same event", + ); + check( + "duplicateDelivery", + duplicates.behavior === "not-observed" + ? accepted.length === 1 + : accepted.length > 1 && + (duplicates.behavior === "additional-run" + ? duplicates.observedRunIds.length > 1 + : duplicates.observedRunIds.length === 1), + "reported duplicate behavior matches observed attempts and runs", + ); + receipt( + "duplicateDelivery", + duplicates.hostObservationRef, + "host-audit", + "host duplicate-delivery behavior receipt", + duplicates.through, + ); + receipt( + "duplicateDelivery", + duplicates.serverObservationRef, + "server", + "server duplicate delivery and accepted reply receipt", + duplicates.through, + ); + } + finish("duplicateDelivery", duplicates !== null); + + const cancellation = record.cancellation; + check( + "cancellation", + cancellation !== null, + "pending-work closure or unsubscribe exercise", + ); + if (cancellation) { + const decision = milliseconds(cancellation.decisionAt); + check( + "cancellation", + cancellation.tableId !== record.tableId && + milliseconds(cancellation.pendingAt) < decision, + "separate cancellation table with work pending before the decision", + ); + check( + "cancellation", + milliseconds(cancellation.coverageFrom) <= + milliseconds(cancellation.pendingAt) && + milliseconds(cancellation.coverageThrough) >= + milliseconds(cancellation.postDecisionProbeAt) && + milliseconds(cancellation.postDecisionProbeAt) > decision, + "attempt-ledger coverage spanning pending work and post-decision refusal", + ); + check( + "cancellation", + unique(cancellation.attempts.map((item) => item.attemptId)), + "unique cancellation attempt identifiers", + ); + for (const attempt of cancellation.attempts) { + check( + "cancellation", + milliseconds(attempt.authorizedAt) < decision, + "no newly authorized delivery after the terminal decision", + true, + ); + check( + "cancellation", + attempt.eventId === cancellation.pendingEventId, + "attempt ledger is scoped to the same pending event", + true, + ); + check( + "cancellation", + milliseconds(attempt.authorizedAt) >= + milliseconds(cancellation.coverageFrom) && + (attempt.dispatchedAt === null || + milliseconds(attempt.dispatchedAt) >= + milliseconds(attempt.authorizedAt)) && + (attempt.completedAt === null || + (attempt.dispatchedAt !== null && + milliseconds(attempt.completedAt) >= + milliseconds(attempt.dispatchedAt) && + milliseconds(attempt.completedAt) <= + milliseconds(cancellation.coverageThrough))), + "authorization, dispatch and completion remain distinct in observed ledger window", + ); + } + receipt( + "cancellation", + cancellation.observationRef, + "server", + "authoritative terminal decision receipt", + cancellation.decisionAt, + ); + receipt( + "cancellation", + cancellation.coverageObservationRef, + "server", + "complete scoped attempt-ledger observation", + cancellation.coverageThrough, + ); + receipt( + "cancellation", + cancellation.refusalObservationRef, + "server", + "post-decision delivery refusal receipt", + cancellation.postDecisionProbeAt, + ); + } + finish("cancellation", cancellation !== null); + + const restart = record.restart; + check( + "restart", + restart !== null, + "retained-state restart and fresh real-host progress", + ); + if (restart) { + const { before, after } = restart; + check( + "restart", + restart.mode === "retained-storage" && + restart.tableId === record.tableId && + before.storageIdentity === after.storageIdentity && + before.retryStateSha256 === after.retryStateSha256, + "same retained storage and unchanged server retry state", + true, + ); + check( + "restart", + before.buildSha === deployment.buildSha && + after.buildSha === deployment.buildSha && + before.configSha256 === deployment.configSha256 && + after.configSha256 === deployment.configSha256, + "same pinned build/configuration across service restart", + ); + const originalSubscriptions = [a.subscriptionId, b.subscriptionId]; + check( + "restart", + before.subscriptionIds.every( + (id, i) => id === originalSubscriptions[i], + ) && + after.subscriptionIds.every((id, i) => id === originalSubscriptions[i]), + "original active subscriptions survive without replacement", + ); + check( + "restart", + milliseconds(before.observedAt) <= milliseconds(restart.startedAt) && + milliseconds(restart.readyAt) >= milliseconds(restart.startedAt) && + milliseconds(after.observedAt) >= milliseconds(restart.readyAt), + "ordered before/restart/ready/after observations", + ); + receipt( + "restart", + restart.operationRef, + "operator", + "storage-preserving restart operation receipt", + restart.readyAt, + ); + receipt( + "restart", + before.observationRef, + "server", + "pre-restart durable-state receipt", + before.observedAt, + ); + receipt( + "restart", + after.observationRef, + "server", + "post-restart durable-state receipt", + after.observedAt, + ); + const resumed = record.turns.find( + (item) => item.id === restart.newTurnId && item.scenario === "restart", + ); + const previousMessages = roundtrip.flatMap((item) => [ + item.post, + ...(item.reply ? [item.reply] : []), + ]); + check( + "restart", + record.turns.filter((item) => item.scenario === "restart").length === 1 && + resumed && + previousMessages.every( + (message) => + message.messageId !== resumed.post.messageId && + message.seq < resumed.post.seq && + milliseconds(message.committedAt) <= + milliseconds(resumed.post.committedAt), + ), + "one genuinely new post and ordered sequence after the round trip", + resumed !== undefined, + ); + check( + "restart", + resumed && + milliseconds(resumed.post.committedAt) > milliseconds(after.observedAt), + "fresh eligible peer post after retained-state restart", + ); + if (resumed) validateTurn(resumed, "restart"); + check( + "restart", + cancellation && + restart.terminalTableId === cancellation.tableId && + restart.terminalSubscriptionId === cancellation.subscriptionId && + milliseconds(cancellation.decisionAt) < + milliseconds(restart.startedAt) && + restart.terminalStateBefore === restart.terminalStateAfter && + restart.terminalStateBefore === + (cancellation.action === "close" ? "closed" : "unsubscribed"), + "the original terminal scope remains terminal after restart", + ); + check( + "restart", + milliseconds(restart.terminalCheckAt) >= milliseconds(restart.readyAt) && + restart.terminalDispatchCount === 0, + "no terminal-scope dispatch resurrection after restart", + ); + receipt( + "restart", + restart.terminalObservationRef, + "server", + "retained terminal-state and denied-dispatch receipt", + restart.terminalCheckAt, + ); + } + finish("restart", restart !== null); + + const isolationActions = [ + "metadata", + "transcript", + "inbox", + "renew-subscription", + "unsubscribe", + ] as const; + for (const person of record.participants) { + for (const action of isolationActions) { + check( + "isolation", + record.isolation.some( + (item) => + item.action === action && + item.targetMembershipId === person.membershipId && + item.targetSubscriptionId === person.subscriptionId, + ), + "third-guest read and subscription-maintenance coverage for both memberships", + ); + } + } + const outsider = record.isolation[0]; + for (const item of record.isolation) { + check( + "isolation", + item.outsiderGuestId === outsider?.outsiderGuestId && + item.outsiderConnectionId === outsider.outsiderConnectionId && + record.participants.every( + (person) => + item.outsiderGuestId !== person.guestId && + item.outsiderConnectionId !== person.connectionId, + ), + "one independently authenticated unaffiliated third guest", + ); + check( + "isolation", + item.tableId === record.tableId && + item.outcome === "denied" && + !item.privateDataReturned && + !item.subscriptionChanged, + "denied access without private content or subscription mutation", + true, + ); + receipt( + "isolation", + item.clientObservationRef, + "host-tool", + "third-guest authenticated client refusal receipt", + item.observedAt, + ); + receipt( + "isolation", + item.serverObservationRef, + "server", + "server denial and unchanged subscription receipt", + item.observedAt, + ); + } + finish("isolation", record.isolation.length > 0); + + const disclosure = record.disclosure; + check( + "disclosure", + disclosure !== null, + "synthetic-only shared transcript review", + ); + if (disclosure) { + check( + "disclosure", + disclosure.scope === "synthetic-only" && + disclosure.syntheticPayloadsSha256 === disclosure.postedPayloadsSha256, + "only deliberately supplied synthetic payloads crossed the relay", + ); + check( + "disclosure", + disclosure.ownerReviewRefs[0] !== disclosure.ownerReviewRefs[1], + "separate owner disclosure reviews", + ); + for (const id of disclosure.ownerReviewRefs) + receipt( + "disclosure", + id, + "owner-consent", + "independent owner synthetic disclosure review", + ); + receipt( + "disclosure", + disclosure.transcriptObservationRef, + "server", + "committed synthetic transcript receipt", + ); + } + finish("disclosure", disclosure !== null); + + const actual = record.evidenceKind === "real-host"; + if (!actual) + missing.push( + "Actual independently observed real-host pilot evidence is required; fixtures are not compatibility results.", + ); + const hasReceipt = ( + id: string | null, + source: AutonomousObservation["source"], + ) => id !== null && observations.get(id)?.source === source; + // Tools and receipt compatibility do not depend on host run observability. + const toolsVerified = + actual && + unique(record.observations.map((item) => item.id)) && + record.observedAt && + observedTimes.every((value) => value <= milliseconds(record.observedAt)) && + deployment.isolated === true && + deployment.buildSha && + deployment.configSha256 && + deployment.endpointRef && + deployment.protocol && + deployment.profile && + hasReceipt(deployment.approvalRef, "operator") && + hasReceipt(deployment.configurationRef, "operator") && + a.role === "A" && + b.role === "B" && + (["ownerRef", "accountRef", "guestId", "connectionId"] as const).every( + (key) => a[key] && b[key] && a[key] !== b[key], + ) && + record.participants.every( + (person) => + person.workspaceRef && + person.host.eligibility === "eligible" && + person.host.origin === "actual-host" && + isKnown(person.host.product) && + isKnown(person.host.surface) && + knownBuild(person.host.exposedBuild) && + hasReceipt(person.oauthEvidenceRef, "host-tool"), + ); + const notificationPrerequisite = + toolsVerified && + immutableMessagesValid && + unique(record.turns.map((item) => item.event.eventId)) && + unique( + record.turns.flatMap((item) => + item.deliveries.map((entry) => entry.attemptId), + ), + ) && + record.tableId && + record.participants.every( + (person) => + person.subscription.tableId === record.tableId && + person.subscription.activeAt && + hasReceipt(person.subscription.observationRef, "server"), + ); + const prerequisite = scenarios.setup === "passed" && actual; + layers.tools = toolsVerified + ? "passed" + : record.evidenceKind === "not-run" + ? "not-run" + : "blocked"; + layers.notificationReceipt = + notificationPrerequisite && + roundResults.length === 2 && + roundResults.every((item) => item.notificationValid) + ? "passed" + : roundtrip.length + ? "blocked" + : "not-run"; + layers.autonomousReply = + prerequisite && scenarios.roundTrip === "passed" + ? "passed" + : scenarios.roundTrip === "failed" + ? "failed" + : roundtrip.length + ? "blocked" + : "not-run"; + return { + structurallyValid: true, + completeRecord: actual && missing.length === 0, + missing: [...new Set(missing)], + layers, + scenarios, + }; +} diff --git a/test/unit/autonomous-evidence.test.ts b/test/unit/autonomous-evidence.test.ts new file mode 100644 index 00000000..47f51d41 --- /dev/null +++ b/test/unit/autonomous-evidence.test.ts @@ -0,0 +1,914 @@ +import { describe, expect, it } from "vitest"; +import { + AUTONOMOUS_SCENARIOS, + type AutonomousEvidence, + type AutonomousObservation, + autonomousEvidenceSchema, + evaluateAutonomousEvidence, +} from "../e2e/host/autonomous-evidence"; +import template from "../e2e/host/autonomous-evidence.template.json"; + +// This fixture is invented validator input, never a real compatibility record. +// Changing its kind in a test exercises only the metadata gate's positive branch. +function syntheticFixture(): AutonomousEvidence { + const record = autonomousEvidenceSchema.parse(template); + record.evidenceKind = "synthetic-validator"; + const at = (seconds: number) => + new Date(Date.parse("2026-11-12T00:00:00Z") + seconds * 1000).toISOString(); + let nextId = 0; + const receipt = (source: AutonomousObservation["source"]) => { + const n = ++nextId; + const id = `fixture-observation-${n}`; + record.observations.push({ + id, + source, + capturedAt: at(250), + sha256: n.toString(16).padStart(64, "0"), + }); + return id; + }; + record.pilotId = "synthetic-validator-only"; + record.observedAt = at(300); + record.tableId = "primary-table-0001"; + record.deployment = { + buildSha: "a".repeat(40), + configSha256: "b".repeat(64), + endpointRef: "isolated-endpoint-0001", + approvalRef: receipt("operator"), + configurationRef: receipt("operator"), + isolated: true, + publicAdvertisement: false, + protocol: "mcp-2025-11-25", + profile: "synthetic-events-profile", + }; + record.participants = record.participants.map((person) => ({ + ...person, + ownerRef: `fixture-owner-${person.role}`, + accountRef: `fixture-account-${person.role}`, + workspaceRef: `fixture-workspace-${person.role}`, + guestId: `fixture-guest-${person.role}`, + connectionId: `fixture-connection-${person.role}`, + membershipId: `fixture-membership-${person.role}`, + subscriptionId: `fixture-subscription-${person.role}`, + host: { + product: `Synthetic Host ${person.role}`, + surface: "Synthetic private app", + exposedBuild: "not-exposed-in-tested-host", + eligibility: "eligible", + origin: "actual-host", + runObservation: "provider-audit", + }, + oauthEvidenceRef: receipt("host-tool"), + subscription: { + tableId: record.tableId, + activeAt: at(1), + observationRef: receipt("server"), + }, + consent: { + approvedAt: at(0), + observationRef: receipt("owner-consent"), + instructionsSha256: "c".repeat(64), + maxReplies: 3, + syntheticOnly: true, + readBeforeReply: true, + reuseMutationKey: true, + stopOnTerminal: true, + suppressAcknowledgmentLoops: true, + askBeforeOutOfScope: true, + }, + })) as AutonomousEvidence["participants"]; + const [a, b] = record.participants; + const makeTurn = ( + id: string, + sender: "A" | "B", + start: number, + seq: number, + scenario: "roundtrip" | "restart", + ): AutonomousEvidence["turns"][number] => { + const from = sender === "A" ? a : b; + const to = sender === "A" ? b : a; + const eventId = `${id}-event`; + const attemptId = `${id}-attempt`; + const runId = `${id}-run`; + const post = { + tableId: record.tableId as string, + messageId: `${id}-post`, + seq, + senderMembershipId: from.membershipId as string, + committedAt: at(start), + mutationKeySha256: seq.toString(16).repeat(64), + observationRef: receipt("server"), + hostObservationRef: receipt("host-tool"), + }; + return { + id, + scenario, + sender, + recipient: sender === "A" ? "B" : "A", + post, + event: { + tableId: post.tableId, + eventId, + messageId: post.messageId, + messageSeq: seq, + senderMembershipId: post.senderMembershipId, + recipientMembershipId: to.membershipId as string, + subscriptionId: to.subscriptionId as string, + committedAt: at(start), + observationRef: receipt("server"), + }, + deliveries: [ + { + attemptId, + eventId, + subscriptionId: to.subscriptionId as string, + authorizedAt: at(start + 1), + dispatchedAt: at(start + 2), + acceptedAt: at(start + 3), + statusCode: 204, + observationRef: receipt("server"), + }, + ], + idle: { + observedAt: at(start - 1), + activeRuns: 0, + observationRef: receipt("host-audit"), + }, + run: { + runId, + triggeringEventId: eventId, + deliveryAttemptId: attemptId, + trigger: "peer-event", + startedAt: at(start + 4), + observationRef: receipt("host-audit"), + }, + read: { + runId, + eventId, + tableId: post.tableId, + membershipId: to.membershipId as string, + connectionId: to.connectionId as string, + afterSeq: seq - 1, + throughSeq: seq, + observedMessageIds: [post.messageId], + readAt: at(start + 5), + hostObservationRef: receipt("host-tool"), + serverObservationRef: receipt("server"), + }, + reply: { + tableId: post.tableId, + runId, + messageId: `${id}-reply`, + seq: seq + 1, + senderMembershipId: to.membershipId as string, + replyToMessageId: post.messageId, + committedAt: at(start + 6), + mutationKeySha256: (seq + 1).toString(16).repeat(64), + observationRef: receipt("server"), + hostObservationRef: receipt("host-tool"), + }, + }; + }; + const first = makeTurn("fixture-first", "A", 10, 1, "roundtrip"); + const back = makeTurn("fixture-return", "B", 16, 2, "roundtrip"); + const firstReply = first.reply as NonNullable; + back.post = { + tableId: firstReply.tableId, + messageId: firstReply.messageId, + seq: firstReply.seq, + senderMembershipId: firstReply.senderMembershipId, + committedAt: firstReply.committedAt, + mutationKeySha256: firstReply.mutationKeySha256, + observationRef: firstReply.observationRef, + hostObservationRef: firstReply.hostObservationRef, + }; + back.event.messageId = back.post.messageId; + if (back.read) back.read.observedMessageIds = [back.post.messageId]; + (back.reply as NonNullable).replyToMessageId = + back.post.messageId; + const resumed = makeTurn("fixture-resumed", "A", 110, 4, "restart"); + record.turns = [first, back, resumed]; + record.retry = { + turnId: first.id, + originalMutationKeySha256: first.post.mutationKeySha256, + retryMutationKeySha256: first.post.mutationKeySha256, + originalPayloadSha256: "1".repeat(64), + retryPayloadSha256: "1".repeat(64), + originalMessageId: first.post.messageId, + retryMessageId: first.post.messageId, + originalSeq: 1, + retrySeq: 1, + beforeCount: 3, + afterCount: 3, + retriedAt: at(30), + observationRef: receipt("server"), + }; + record.duplicateDelivery = { + turnId: first.id, + eventId: first.event.eventId, + acceptedAttemptIds: [first.deliveries[0].attemptId], + observedRunIds: [first.run?.runId as string], + acceptedReplyMessageIds: [firstReply.messageId], + behavior: "not-observed", + through: at(60), + hostObservationRef: receipt("host-audit"), + serverObservationRef: receipt("server"), + }; + record.cancellation = { + tableId: "cancellation-table-0001", + action: "close", + subscriptionId: "cancellation-subscription-0001", + pendingEventId: "cancellation-pending-0001", + pendingAt: at(40), + decisionAt: at(50), + observationRef: receipt("server"), + attempts: [ + { + attemptId: "cancellation-inflight-0001", + eventId: "cancellation-pending-0001", + authorizedAt: at(41), + dispatchedAt: at(42), + completedAt: at(55), + }, + ], + coverageFrom: at(35), + coverageThrough: at(70), + coverageObservationRef: receipt("server"), + postDecisionProbeAt: at(65), + refusalObservationRef: receipt("server"), + }; + const before = { + storageIdentity: "retained-storage-0001", + buildSha: record.deployment.buildSha as string, + configSha256: record.deployment.configSha256 as string, + retryStateSha256: "2".repeat(64), + subscriptionIds: [ + a.subscriptionId as string, + b.subscriptionId as string, + ] as [string, string], + observedAt: at(100), + observationRef: receipt("server"), + }; + record.restart = { + mode: "retained-storage", + tableId: record.tableId, + startedAt: at(101), + readyAt: at(102), + operationRef: receipt("operator"), + before, + after: { + ...before, + observedAt: at(103), + observationRef: receipt("server"), + }, + newTurnId: resumed.id, + terminalTableId: record.cancellation.tableId, + terminalSubscriptionId: record.cancellation.subscriptionId, + terminalStateBefore: "closed", + terminalStateAfter: "closed", + terminalCheckAt: at(120), + terminalDispatchCount: 0, + terminalObservationRef: receipt("server"), + }; + for (const person of record.participants) { + for (const action of [ + "metadata", + "transcript", + "inbox", + "renew-subscription", + "unsubscribe", + ] as const) { + record.isolation.push({ + action, + outsiderGuestId: "unaffiliated-guest-0001", + outsiderConnectionId: "unaffiliated-connection-0001", + tableId: record.tableId, + targetMembershipId: person.membershipId as string, + targetSubscriptionId: person.subscriptionId as string, + observedAt: at(80), + outcome: "denied", + privateDataReturned: false, + subscriptionChanged: false, + clientObservationRef: receipt("host-tool"), + serverObservationRef: receipt("server"), + }); + } + } + record.disclosure = { + syntheticPayloadsSha256: "3".repeat(64), + postedPayloadsSha256: "3".repeat(64), + scope: "synthetic-only", + ownerReviewRefs: [receipt("owner-consent"), receipt("owner-consent")], + transcriptObservationRef: receipt("server"), + }; + return record; +} + +function realHostShapedFixture() { + const record = syntheticFixture(); + record.evidenceKind = "real-host"; + return record; +} + +describe("autonomous pilot metadata gate", () => { + it("keeps the committed unrun template explicit and incomplete", () => { + const result = evaluateAutonomousEvidence(template); + expect(result.structurallyValid).toBe(true); + expect(result.completeRecord).toBe(false); + expect(Object.values(result.layers)).toEqual([ + "not-run", + "not-run", + "not-run", + ]); + expect(result.missing.length).toBeGreaterThan(0); + }); + + it("never turns a complete synthetic fixture into host compatibility evidence", () => { + const result = evaluateAutonomousEvidence(syntheticFixture()); + expect(result.structurallyValid).toBe(true); + for (const scenario of AUTONOMOUS_SCENARIOS) + expect(result.scenarios[scenario]).toBe("passed"); + expect(result.completeRecord).toBe(false); + expect(result.layers.autonomousReply).toBe("blocked"); + }); + + it("accepts a complete real-host-shaped metadata join without authenticating its receipts", () => { + // Positive validator branch only: this invented record is not saved as evidence. + const result = evaluateAutonomousEvidence(realHostShapedFixture()); + expect(result.missing).toEqual([]); + expect(result.completeRecord).toBe(true); + expect(result.layers).toEqual({ + tools: "passed", + notificationReceipt: "passed", + autonomousReply: "passed", + }); + }); + + it("reports tools and receipt separately when receiver 2xx has no observed host run", () => { + const record = realHostShapedFixture(); + for (const item of record.turns) { + item.idle = null; + item.run = null; + item.read = null; + item.reply = null; + } + const result = evaluateAutonomousEvidence(record); + expect(result.layers.tools).toBe("passed"); + expect(result.layers.notificationReceipt).toBe("passed"); + expect(result.layers.autonomousReply).toBe("blocked"); + expect(result.completeRecord).toBe(false); + }); + + it("does not certify unsupported run observation from previously verified tools", () => { + const record = realHostShapedFixture(); + record.participants[1].host.runObservation = "unsupported"; + const result = evaluateAutonomousEvidence(record); + expect(result.layers.tools).toBe("passed"); + expect(result.layers.notificationReceipt).toBe("passed"); + expect(result.layers.autonomousReply).toBe("blocked"); + expect(result.completeRecord).toBe(false); + }); + + const mutations: [string, (record: AutonomousEvidence) => void][] = [ + [ + "one owner operating both accounts", + (r) => { + r.participants[1].ownerRef = r.participants[0].ownerRef; + }, + ], + [ + "one grant relabeled as a second participant", + (r) => { + r.participants[1].connectionId = r.participants[0].connectionId; + }, + ], + [ + "missing independent owner consent", + (r) => { + r.participants[1].consent.observationRef = null; + }, + ], + [ + "unapproved synthetic disclosure scope", + (r) => { + r.participants[1].consent.syntheticOnly = false; + }, + ], + [ + "SDK-only replacement", + (r) => { + r.participants[1].host.origin = "sdk-only"; + }, + ], + [ + "unisolated deployment", + (r) => { + r.deployment.isolated = false; + }, + ], + [ + "public support advertisement during private verification", + (r) => { + r.deployment.publicAdvertisement = true; + }, + ], + [ + "missing deployed build configuration", + (r) => { + r.deployment.configSha256 = null; + }, + ], + [ + "another subscription", + (r) => { + r.turns[0].event.subscriptionId = "unrelated-subscription"; + }, + ], + [ + "another event", + (r) => { + r.turns[0].deliveries[0].eventId = "unrelated-event-0001"; + }, + ], + [ + "another committed message", + (r) => { + r.turns[0].event.messageId = "unrelated-message-0001"; + }, + ], + [ + "manual continuation", + (r) => { + if (r.turns[0].run) r.turns[0].run.trigger = "manual"; + }, + ], + [ + "scheduled polling", + (r) => { + if (r.turns[0].run) r.turns[0].run.trigger = "polling"; + }, + ], + [ + "scripted host replacement", + (r) => { + if (r.turns[0].run) r.turns[0].run.trigger = "scripted"; + }, + ], + [ + "model claim replacing host audit", + (r) => { + const observation = r.observations.find( + (item) => item.id === r.turns[0].run?.observationRef, + ); + if (observation) observation.source = "host-tool"; + }, + ], + [ + "already active target", + (r) => { + if (r.turns[0].idle) r.turns[0].idle.activeRuns = 1; + }, + ], + [ + "run before event dispatch", + (r) => { + if (r.turns[0].run) r.turns[0].run.startedAt = "2026-11-12T00:00:00Z"; + }, + ], + [ + "read in unrelated run", + (r) => { + if (r.turns[0].read) r.turns[0].read.runId = "unrelated-run-0001"; + }, + ], + [ + "read using another connection", + (r) => { + if (r.turns[0].read) + r.turns[0].read.connectionId = r.participants[0] + .connectionId as string; + }, + ], + [ + "read before triggering message", + (r) => { + if (r.turns[0].read) r.turns[0].read.throughSeq = 0; + }, + ], + [ + "empty read at a snapshot upper bound containing the trigger", + (r) => { + if (r.turns[0].read) r.turns[0].read.observedMessageIds = []; + }, + ], + [ + "read returning only unrelated messages", + (r) => { + if (r.turns[0].read) + r.turns[0].read.observedMessageIds = ["unrelated-message-0001"]; + }, + ], + [ + "read cursor skipping the triggering message", + (r) => { + if (r.turns[0].read) r.turns[0].read.afterSeq = r.turns[0].post.seq; + }, + ], + [ + "reply to unrelated message", + (r) => { + if (r.turns[0].reply) + r.turns[0].reply.replyToMessageId = "unrelated-message-0001"; + }, + ], + [ + "reply using sender membership", + (r) => { + if (r.turns[0].reply) + r.turns[0].reply.senderMembershipId = r.participants[0] + .membershipId as string; + }, + ], + [ + "disconnected return turn", + (r) => { + r.turns[1].post.messageId = "disconnected-message-0001"; + r.turns[1].event.messageId = r.turns[1].post.messageId; + if (r.turns[1].reply) + r.turns[1].reply.replyToMessageId = r.turns[1].post.messageId; + }, + ], + [ + "new mutation key on retry", + (r) => { + if (r.retry) r.retry.retryMutationKeySha256 = "9".repeat(64); + }, + ], + [ + "changed payload on retry", + (r) => { + if (r.retry) r.retry.retryPayloadSha256 = "9".repeat(64); + }, + ], + [ + "extra accepted retry message", + (r) => { + if (r.retry) r.retry.afterCount += 1; + }, + ], + [ + "new retry receipt identity", + (r) => { + if (r.retry) r.retry.retryMessageId = "unrelated-message-0001"; + }, + ], + [ + "unreported duplicate acceptance", + (r) => { + if (r.duplicateDelivery) + r.duplicateDelivery.acceptedAttemptIds.push( + "unobserved-attempt-0001", + ); + }, + ], + [ + "invented exactly-once duplicate behavior", + (r) => { + if (r.duplicateDelivery) + r.duplicateDelivery.behavior = "no-additional-run"; + }, + ], + [ + "post-terminal delivery authorization", + (r) => { + if (r.cancellation) + r.cancellation.attempts[0].authorizedAt = "2026-11-12T00:00:51Z"; + }, + ], + [ + "cancellation without pending work", + (r) => { + if (r.cancellation) r.cancellation.pendingAt = "2026-11-12T00:01:00Z"; + }, + ], + [ + "truncated post-terminal coverage", + (r) => { + if (r.cancellation) + r.cancellation.coverageThrough = "2026-11-12T00:00:49Z"; + }, + ], + [ + "fresh-storage replacement", + (r) => { + if (r.restart) r.restart.mode = "fresh-storage"; + }, + ], + [ + "retry budget reset", + (r) => { + if (r.restart) r.restart.after.retryStateSha256 = "9".repeat(64); + }, + ], + [ + "replacement active subscription", + (r) => { + if (r.restart) + r.restart.after.subscriptionIds[0] = "replacement-subscription"; + }, + ], + [ + "replacement table after restart", + (r) => { + if (r.restart) r.restart.tableId = "replacement-table-0001"; + }, + ], + [ + "terminal dispatch resurrection", + (r) => { + if (r.restart) r.restart.terminalDispatchCount = 1; + }, + ], + [ + "missing fresh real-host resumed turn", + (r) => { + r.turns = r.turns.filter((item) => item.scenario !== "restart"); + }, + ], + [ + "third guest transcript leak", + (r) => { + r.isolation[1].privateDataReturned = true; + }, + ], + [ + "third guest modifying subscription", + (r) => { + r.isolation[3].subscriptionChanged = true; + }, + ], + [ + "missing other owner's unsubscribe denial", + (r) => { + r.isolation.pop(); + }, + ], + [ + "member reused as unaffiliated guest", + (r) => { + r.isolation[0].outsiderGuestId = r.participants[0].guestId as string; + }, + ], + [ + "unapproved content crossing relay", + (r) => { + if (r.disclosure) r.disclosure.postedPayloadsSha256 = "9".repeat(64); + }, + ], + [ + "missing named external receipt", + (r) => { + r.observations.pop(); + }, + ], + [ + "missing same-run host reply tool receipt", + (r) => { + r.observations = r.observations.filter( + (item) => item.id !== r.turns[0].reply?.hostObservationRef, + ); + }, + ], + [ + "missing actual initiating agent post tool receipt", + (r) => { + r.observations = r.observations.filter( + (item) => item.id !== r.turns[0].post.hostObservationRef, + ); + }, + ], + [ + "server receipt substituted for host reply tool trace", + (r) => { + const observation = r.observations.find( + (item) => item.id === r.turns[0].reply?.hostObservationRef, + ); + if (observation) observation.source = "server"; + }, + ], + [ + "host reply tool trace predating committed reply", + (r) => { + const observation = r.observations.find( + (item) => item.id === r.turns[0].reply?.hostObservationRef, + ); + if (observation) observation.capturedAt = "2026-11-12T00:00:15Z"; + }, + ], + [ + "duplicate receipt identifier", + (r) => { + r.observations.push({ ...r.observations[0] }); + }, + ], + [ + "ambiguous undisclosed build", + (r) => { + r.participants[0].host.exposedBuild = "not-exposed-unspecified"; + }, + ], + [ + "idle observed only after the triggering post", + (r) => { + if (r.turns[0].idle) + r.turns[0].idle.observedAt = "2026-11-12T00:00:11Z"; + }, + ], + [ + "report predating its evidence", + (r) => { + r.observedAt = "2026-11-12T00:00:01Z"; + }, + ], + [ + "host run receipt captured before the run", + (r) => { + const observation = r.observations.find( + (item) => item.id === r.turns[0].run?.observationRef, + ); + if (observation) observation.capturedAt = "2026-11-12T00:00:01Z"; + }, + ], + [ + "unrelated cancellation ledger event", + (r) => { + if (r.cancellation) + r.cancellation.attempts[0].eventId = "unrelated-event-0001"; + }, + ], + [ + "exceeded independently approved reply bound", + (r) => { + r.participants[1].consent.maxReplies = 1; + }, + ], + [ + "reused event across resumed host turns", + (r) => { + r.turns[2].event.eventId = r.turns[0].event.eventId; + r.turns[2].deliveries[0].eventId = r.turns[0].event.eventId; + if (r.turns[2].run) + r.turns[2].run.triggeringEventId = r.turns[0].event.eventId; + if (r.turns[2].read) r.turns[2].read.eventId = r.turns[0].event.eventId; + }, + ], + [ + "reused host run after restart", + (r) => { + if ( + r.turns[2].run && + r.turns[2].read && + r.turns[2].reply && + r.turns[0].run + ) { + r.turns[2].run.runId = r.turns[0].run.runId; + r.turns[2].read.runId = r.turns[0].run.runId; + r.turns[2].reply.runId = r.turns[0].run.runId; + } + }, + ], + [ + "reused delivery attempt after restart", + (r) => { + r.turns[2].deliveries[0].attemptId = r.turns[0].deliveries[0].attemptId; + if (r.turns[2].run) + r.turns[2].run.deliveryAttemptId = r.turns[0].deliveries[0].attemptId; + }, + ], + [ + "same accepted message relabeled as fresh after restart", + (r) => { + r.turns[2].post.messageId = r.turns[0].post.messageId; + r.turns[2].event.messageId = r.turns[0].post.messageId; + if (r.turns[2].reply) + r.turns[2].reply.replyToMessageId = r.turns[0].post.messageId; + }, + ], + [ + "prior sequence committed only after the restarted peer post", + (r) => { + if (r.turns[1].reply) + r.turns[1].reply.committedAt = "2026-11-12T00:03:20Z"; + if (r.retry) { + r.retry.beforeCount = 2; + r.retry.afterCount = 2; + } + }, + ], + [ + "same sender mutation key reused for new post", + (r) => { + r.turns[2].post.mutationKeySha256 = r.turns[0].post.mutationKeySha256; + }, + ], + [ + "second unvalidated restart turn", + (r) => { + r.turns.push({ ...r.turns[2], id: "extra-restart-turn-0001" }); + }, + ], + ]; + it.each(mutations)("rejects %s", (_name, mutate) => { + const record = realHostShapedFixture(); + mutate(record); + const result = evaluateAutonomousEvidence(record); + expect(result.completeRecord).toBe(false); + expect(result.missing.length).toBeGreaterThan(0); + }); + + it("separates pre-decision dispatched traffic from forbidden new authorization", () => { + const record = realHostShapedFixture(); + expect(record.cancellation?.attempts[0].completedAt).toBe( + "2026-11-12T00:00:55.000Z", + ); + expect(evaluateAutonomousEvidence(record).scenarios.cancellation).toBe( + "passed", + ); + }); + + it("distinguishes failed observed behavior from missing blocked observations", () => { + const manual = realHostShapedFixture(); + if (manual.turns[0].run) manual.turns[0].run.trigger = "manual"; + expect(evaluateAutonomousEvidence(manual).scenarios.roundTrip).toBe( + "failed", + ); + const missing = realHostShapedFixture(); + missing.turns[0].run = null; + expect(evaluateAutonomousEvidence(missing).scenarios.roundTrip).toBe( + "blocked", + ); + const leaked = realHostShapedFixture(); + leaked.isolation[0].outcome = "allowed"; + expect(evaluateAutonomousEvidence(leaked).scenarios.isolation).toBe( + "failed", + ); + }); + + it("records duplicated delivery with additional observed host runs honestly", () => { + const record = realHostShapedFixture(); + const first = record.turns[0]; + first.deliveries.push({ + ...first.deliveries[0], + attemptId: "duplicate-attempt-0002", + }); + const duplicates = record.duplicateDelivery as NonNullable< + typeof record.duplicateDelivery + >; + duplicates.acceptedAttemptIds.push("duplicate-attempt-0002"); + duplicates.observedRunIds.push("duplicate-host-run-0002"); + duplicates.acceptedReplyMessageIds.push("duplicate-reply-0002"); + duplicates.behavior = "additional-run"; + expect(evaluateAutonomousEvidence(record).scenarios.duplicateDelivery).toBe( + "passed", + ); + }); + + it("rejects duplicate behavior asserted before the duplicate was accepted", () => { + const record = realHostShapedFixture(); + const first = record.turns[0]; + first.deliveries.push({ + ...first.deliveries[0], + attemptId: "late-duplicate-attempt", + authorizedAt: "2026-11-12T00:03:18Z", + dispatchedAt: "2026-11-12T00:03:19Z", + acceptedAt: "2026-11-12T00:03:20Z", + }); + const duplicates = record.duplicateDelivery as NonNullable< + typeof record.duplicateDelivery + >; + duplicates.acceptedAttemptIds.push("late-duplicate-attempt"); + duplicates.behavior = "no-additional-run"; + const result = evaluateAutonomousEvidence(record); + expect(result.completeRecord).toBe(false); + expect(result.scenarios.duplicateDelivery).toBe("failed"); + expect(result.missing).toContain( + "duplicateDelivery: duplicate observation window covers every included receiver acceptance", + ); + }); + + it("rejects secret-bearing unknown keys without echoing keys, values or URLs", () => { + const sentinel = "SECRET_DO_NOT_ECHO_123456789"; + const input = { + ...syntheticFixture(), + [sentinel]: `https://private.example/${sentinel}`, + }; + const result = evaluateAutonomousEvidence(input); + expect(result.structurallyValid).toBe(false); + expect(JSON.stringify(result)).not.toContain(sentinel); + const nested = syntheticFixture(); + Object.assign(nested.turns[0].deliveries[0], { + callbackUrl: `https://private.example/${sentinel}`, + }); + expect(evaluateAutonomousEvidence(nested).structurallyValid).toBe(false); + }); +});