diff --git a/.github/workflows/silent-chicken.yml b/.github/workflows/silent-chicken.yml new file mode 100644 index 00000000..ada1cc95 --- /dev/null +++ b/.github/workflows/silent-chicken.yml @@ -0,0 +1,85 @@ +name: Admitted intent and continuity +on: + pull_request: + paths: + - 'sdk/typescript/**' + - 'mcp/**' + - 'examples/summa42-*/**' + - '.github/workflows/silent-chicken.yml' +permissions: + contents: read +jobs: + hostile-recovery: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + execution: + image: postgres:18.6 + env: + POSTGRES_USER: once_fixture + POSTGRES_PASSWORD: fixture-only-ci + ports: ['5432:5432'] + options: --health-cmd "pg_isready -U once_fixture" --health-interval 5s --health-timeout 5s --health-retries 15 + witness: + image: postgres:18.6 + env: + POSTGRES_USER: once_fixture + POSTGRES_PASSWORD: fixture-only-ci + ports: ['5433:5432'] + options: --health-cmd "pg_isready -U once_fixture" --health-interval 5s --health-timeout 5s --health-retries 15 + env: + ONCE_TEST_POSTGRES: postgresql://once_fixture:fixture-only-ci@127.0.0.1:5432/postgres + ONCE_TEST_WITNESS: postgresql://once_fixture:fixture-only-ci@127.0.0.1:5433/postgres + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24.19.0' + - uses: actions/setup-go@v5 + with: + go-version: '1.27.1' + cache: false + - name: Qualify authority kernel and two-cluster witness + run: | + npm --prefix sdk/typescript ci --ignore-scripts --no-audit --no-fund + npm --prefix sdk/typescript run test:authority-kernel + npm --prefix sdk/typescript run test:shared-authority + (cd sdk/typescript && npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP" --silent) + - name: Preserve published-SDK default compatibility + run: | + npm --prefix mcp ci --ignore-scripts --no-audit --no-fund + npm --prefix mcp run build + node --test mcp/test/intent-capability.test.mjs + npm --prefix mcp run test:registered-action-package + - name: Qualify opt-in intent authority and crash evidence + working-directory: mcp + run: | + npm install --no-save --package-lock=false --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP/once-agent-sdk-0.1.27.tgz" + npm run build + node scripts/silent-chicken-evidence.mjs "$RUNNER_TEMP/hardening" + node scripts/verify-hardening.mjs protected "$(find "$RUNNER_TEMP/hardening" -mindepth 1 -maxdepth 1 -type d | head -1)" + - name: Build against actual pinned Summa42 services + run: | + git clone https://github.com/SofiaFlux/summa42.git "$RUNNER_TEMP/summa42" + git -C "$RUNNER_TEMP/summa42" checkout efc6794ea79a4be5b2f60e376833a1d03cba053c + mkdir -p "$RUNNER_TEMP/summa42/cmd/once-evidence" + cp examples/summa42-host-integration/main.go "$RUNNER_TEMP/summa42/cmd/once-evidence/main.go" + cd "$RUNNER_TEMP/summa42" + GOCACHE=/tmp/summa42-full-go-cache go test -race ./internal/operations/... ./internal/execution/... -count=1 -timeout 5m + GOCACHE=/tmp/summa42-full-go-cache go build -o "$RUNNER_TEMP/summa42-once-fixture" ./cmd/once-evidence + - name: Kill and recover actual Summa42 service worker + env: + ONCE_SUMMA42_BINARY: ${{ runner.temp }}/summa42-once-fixture + ONCE_SUMMA42_REVISION: efc6794ea79a4be5b2f60e376833a1d03cba053c + run: | + node mcp/scripts/summa42-host-proof.mjs "$RUNNER_TEMP/summa42-host" + node mcp/scripts/verify-summa42-host.mjs "$(find "$RUNNER_TEMP/summa42-host" -mindepth 1 -maxdepth 1 -type d | head -1)" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: silent-chicken-evidence + path: | + ${{ runner.temp }}/hardening + ${{ runner.temp }}/summa42-host diff --git a/docs/SILENT_CHICKEN.md b/docs/SILENT_CHICKEN.md new file mode 100644 index 00000000..c2149a36 --- /dev/null +++ b/docs/SILENT_CHICKEN.md @@ -0,0 +1,64 @@ +# Silent Chicken: host-owned intent and continuity authority + +This candidate closes the measured T06/T07 gaps **in an explicitly configured trusted host profile**. It reuses PR #303's `ExecutionStore` seam and PostgreSQL continuity adapter underneath the existing `protectLocal` kernel; it does not create another dispatcher. Source candidates are SDK 0.1.27 and MCP 0.2.3. Public-current SDK 0.1.25, MCP 0.2.1 and plugin pins remain unchanged until a separate qualified publication. PR #303 and the independent MCP 0.2.2 candidate remain separate review work. + +## Identity, admission and trust + +`admitPostgresIntent` is a trusted **host admission** operation, not a tool. The host supplies a durable scope and semantic intent key, based on authoritative task/effect-slot data. The registry generates a distinct operation UUID, stores the complete canonical effect fingerprint, and binds it under the pinned execution authority. Re-admission of that same trusted key returns the original binding; changed inputs are CONFLICT. Another intentional action with identical inputs requires another genuinely authorized trusted key. The payload hash is a binding, never the logical identity. + +The runtime only calls `createPostgresIntentAuthority(...).resolveIntent(scope,key)`. Missing, damaged or ambiguous intent information denies execution; it cannot initialize a replacement mapping. The MCP host obtains the key from authenticated host context, not tool arguments. A caller operation ID that differs from the admitted ID returns IDENTITY_CONFLICT. Changes to SKU, quantity, destination, provider account, resource or tool conflict before dispatch. + +```mermaid +flowchart LR + Owner[Trusted host admission principal] -->|explicit intent admission| Registry[Immutable intent mapping] + Registry --- Exec[(Execution PostgreSQL authority)] + Host[Authenticated task and effect-slot context] --> Gate[MCP intent admission gate] + Planner[Planner arguments and transport IDs] --> Gate + Gate -->|lookup existing binding| Registry + Gate --> Kernel[Existing protectLocal kernel] + Kernel -->|claim / fence / UNKNOWN / receipt| Exec + Exec -->|conditional revision and state-root advance before commit| Witness[(Independent continuity PostgreSQL)] + Kernel -->|one admitted unary create| Provider[Non-idempotent provider] + Provider --> Journal[(Provider-owned fsynced effect journal)] + Provider -->|authoritative read-only truth| Kernel +``` + +The host pins execution authority ID, generation and epoch, plus independent witness identity. These pins are operator configuration outside the potentially replaced execution history. Runtime SQL roles cannot admit intents, erase history or provision schemas. Admission credentials are not given to the planner or runtime worker. Different pooled connections do not establish independent failure/restore domains: deployment owners must actually provision and authenticate that separation. + +## Transaction and crash protocol + +Every short authority transaction locks its namespace metadata row, requires PostgreSQL `fsync`, `full_page_writes` and synchronous commit, reads database time and validates pinned schema/generation/epoch. It performs the existing claim/read/update and conditionally advances the independent witness from revision r to r+1, including the old/new full-namespace state digests, **before** committing the matching execution revision and digest. No execution right is returned until acknowledgement completes. Dispatch additionally fences the exact owner/fingerprint/live lease, rechecks expiry after witness admission, and rechecks selectors before the provider callback. + +If witness acknowledgement or execution commit is ambiguous, the witness may be ahead. Later access fails closed; CAS is not blindly retried and the adapter never rewinds or recreates either history. This intentionally sacrifices availability. SQL/driver failures do not expose raw credential-bearing causes. Schema scripts are explicit operator provisioning only, with no `IF NOT EXISTS` recovery or startup DDL. The explicit `execution-host-intents-v2.sql` and `continuity-witness-v2.sql` migrations require operator-seeded schema-v2 checkpoints; they do not reinterpret existing SQLite or revision-only history. Scope and intent keys are canonical ASCII identifiers, 1-512 characters from letters, digits, dot, underscore, colon, slash and hyphen. + +An abandoned claim expires to UNKNOWN; it never grants a replacement owner permission to dispatch. A crash between reservation and external execution can therefore leave **zero effects and indefinite UNKNOWN**. No guessed CONFIRMED is returned. ABSENT, NOT_FOUND, unavailable lookup, stale, incomplete or mismatched evidence never authorizes redispatch. Exact authoritative positive evidence can durably confirm and replay. Contradictory concurrent confirmations now return CONFLICT to the losing observer and preserve the original durable receipt; the prior test was strengthened accordingly. + +## Minimum SQL capabilities + +Provision schemas and seed matching authority/witness metadata and initial checkpoints using separate operator credentials. For one execution domain, runtime needs schema USAGE; SELECT and UPDATE(revision,state_digest) on authorities; SELECT/INSERT and UPDATE(state,result_json,owner,lease_until) on operations; SELECT only on host_intents. The admission principal needs authority SELECT/UPDATE(revision,state_digest), intent SELECT/INSERT, and read-only operation access to verify the full namespace state. The witness principal needs schema USAGE, metadata SELECT/UPDATE(schema_version) for row-lock privilege, and checkpoint SELECT/UPDATE(revision,state_digest). Neither runtime nor admission should have DDL, DELETE, TRUNCATE, superuser, role creation, database creation, replication or bypass-RLS authority. Use distinct credentials and per-domain isolation/RLS as appropriate; broad multi-tenant grants are not implied by the fixture. + +SQL principals executing the kernel and their host-supplied pools are trusted. This does not protect against arbitrary malicious SQL issued using those trusted credentials. The immutable mapping has no runtime UPDATE/DELETE capability, and all normal changes pass the monotonic witness protocol. + +## Migration and compatibility + +Legacy SQLite and default MCP helper behavior remain explicit legacy modes. They do not acquire identity admission or cross-restart rollback protection silently. The original baseline T06/T07 counterexamples remain in the suite and still apply to that configuration. Installing only the default MCP package retains its pinned published SDK 0.1.25 dependency. Requesting the new profile on that SDK returns UNSUPPORTED_BOUNDARY before any provider call; it never falls back to SQLite. + +The experimental profile requires the exact qualified SDK candidate and MCP candidate in the same dependency graph. The evidence package provides local tarballs and an explicit npm override, retaining separate published-SDK compatibility tests. Any future public packaging change must qualify that pair and update minimum-version guidance before advertising availability. SDK 0.1.27 also includes the previously unmerged shared-authority seam, which needs coordinated review with PR #303; this is not a declaration that PR #303's production gates have cleared. + +For a new authorized test domain: provision execution and independently protected witness stores, seed trusted pins/checkpoint, apply the additive intent schema, admit a trusted intent, and only then start runtime workers. For existing real operations: quiesce writers, preserve original receipts/UNKNOWN state and provider identity, obtain authoritative provider evidence, and construct a reviewed complete migration. **No automatic SQLite-to-empty-PostgreSQL migration is supplied.** Losing history does not authorize fresh bootstrap. Recovery never accepts an empty valid-looking ledger as proof that an old action did not happen. + +## Evidence and threat boundaries + +The historical baseline independently reproduces two effects for T06 and T07. The same maximum-one-effect protection invariant fails RED on that baseline; it is not declared safe because an old test expected the defect. New H01/H02 tests require IDENTITY_CONFLICT / CONTINUITY_LOST and independently one provider effect. A separately admitted identical action remains possible. Other tests cover real process kills before/after effect, repeated replacement workers, concurrent workers, changed bindings, failed/forged provider truth, missing/rewound continuity, unavailable storage, corrupted mappings and SQL privilege denials. Saved artifacts include machine results, event traces, provider journals and durable authority snapshots. Independent checkers import only Node built-ins. + +The [actual Summa42 service adapter](../examples/summa42-host-integration/README.md) builds against pinned public revision efc6794ea79a4be5b2f60e376833a1d03cba053c and kills/restarts actual Go service workers. It verifies task/slot identity, unresolved resource exposure and confirmed recovery through the new narrow provider. It does not run the full Box/LLM/planner or gate every unrelated scheduler action. The reference host reconciles before StartAttempt and denies replacement Attempt progression while unresolved; unrelated organizational commitments and the full Box scheduler still need the host-wide integration. Do not claim full #35 closure from a boundary test alone. + +Both PostgreSQL clusters in local proof run on one physical machine. Container CI also shares one runner. Independent physical restore domains, verified production TLS, HA, network partitions across real regions, hostile operators, compromised trusted hosts/credentials and coordinated rollback of both stores/configuration are unqualified. Provider truth is trusted only through the reviewed adapter; malicious providers and forged authoritative records outside that trust cannot be solved by a local operation ID. An admitted request cannot be recalled after being issued. Hidden retries inside an opaque provider callback can still create multiple effects; the qualified adapter performs one unary HTTP create, and the existing negative test preserves that limitation. + +Namespace-wide locking, full-namespace state verification and witness advancement even on reads create a deliberate coordination/availability cost. The digest scan is O(namespace rows) in work and memory; this small profile does not claim high-throughput scalability. Recorded timings are fixture measurements, not a production SLA, throughput guarantee, cloud price or RTO/RPO. No real payment/order/refund, production credentials, universal exactly-once execution, upstream merger or commercial adoption is claimed. Release and merge remain subject to current-head verification and review; source candidate versions are not publication evidence. + +## State-bound v2 and the retained revision-only RED control + +A review probe reproduced **two effects under the same operation ID** after deleting operation rows while preserving current revision/metadata on the earlier revision-only candidate. That control and its exact tarballs are retained. Counter equality alone is insufficient for partial-history replacement. The v2 host-intent profile recomputes a canonical SHA-256 commitment over all operation rows and immutable intent mappings inside the locked transaction, verifies the old commitment and conditionally commits the new commitment to the separately authenticated witness before the execution commit. Copying current metadata or locally rehashing empty history does not satisfy the independent witness. H02 exercises both attacks and a coherent rewind before exact recovery. + +This is **not** trusting a local checksum or sibling marker: the expected content commitment is independently durable, authenticated, conditionally advanced and pinned to authority/generation/epoch/revision. Bootstrap of a new empty namespace is an explicit operator action; runtime never seeds a missing commitment. Migration of existing revision-only history requires a quiesced, independently verified complete history and trusted operator checkpoint admission. Merely hashing whatever restored ledger happens to exist is not a safe migration. The retained `createPostgresExecutionAuthority` v1 adapter is a draft revision-only compatibility/control surface; it does not qualify for host-intent v2. MCP requires the explicit `once-host-intent-v2` module/capability contract and refuses a downgrade even when candidate version strings happen to match. diff --git a/evolution/constitution/protected-hashes.json b/evolution/constitution/protected-hashes.json index 2113a1a1..ffa8cedb 100644 --- a/evolution/constitution/protected-hashes.json +++ b/evolution/constitution/protected-hashes.json @@ -24,7 +24,7 @@ "evolution/kernel/docs-evaluator.mjs": "82f46351882278862a7c05385445be8fb256e1b52d7a23ad2452710a3f28557b", "evolution/kernel/evaluate.mjs": "8123001819bba5388d02704371f5bbf165be7fb721fdd2b9a7d6cb1ebf469e37", "evolution/kernel/hash-manifest.mjs": "babddbdf899a70e533e4bc7c816f6aea83a867ba7c09f263193a5d060845e87c", - "evolution/kernel/mutations.mjs": "a1e36783831cf29f2f77af6713fa1422f1b139a991b5d7f97e88c8e0bf1eba00", + "evolution/kernel/mutations.mjs": "9b084b9911aa6d27039dbd8921bb47836b3579d07ebe7cf991cb0bba288b56d1", "evolution/kernel/safety-worker.mjs": "7e90aac20b36a551e10271d0e842e4dbd54fffb843827bb40c4dbf48306b0036", "evolution/kernel/schema.mjs": "ac8a88e8e315729b848783c580b14f05535406edc07528ba7033b422257d2dea", "evolution/schemas/receipt.schema.json": "aa2115ad5ef03446be38d2b3dde7fcc3b5f8fe36a96d22e98f20ad696e800bbc", diff --git a/evolution/kernel/mutations.mjs b/evolution/kernel/mutations.mjs index 4b1f5d02..ce5588a9 100644 --- a/evolution/kernel/mutations.mjs +++ b/evolution/kernel/mutations.mjs @@ -8,7 +8,7 @@ export const mutations = [ {id:'absent-redispatch',file:'local.js',from:'if (observation?.state !== "CONFIRMED") {',to:'if (observation?.state === "ABSENT") return operation.apply(this, callArgs);\n if (observation?.state !== "CONFIRMED") {'}, {id:'error-fake-success',file:'local.js',from:'throw new LocalProtectionError("UNKNOWN", `ORIGINAL OUTCOME UNKNOWN.',to:'return { fakeSuccess: true }; throw new LocalProtectionError("UNKNOWN", `ORIGINAL OUTCOME UNKNOWN.'}, {id:'skip-reconcile',file:'local.js',from:'observation = await options.reconcile({ id, payload: copyData(payload) });',to:'observation = {state:"CONFIRMED",result:{fakeSuccess:true}};'}, - {id:'invert-guard',file:'local.js',from:'if (row.state === "CLAIMED" && row.lease_until > Date.now()) {',to:'if (row.state === "CLAIMED" && row.lease_until < Date.now()) {'}, + {id:'invert-guard',file:'local.js',from:'if (row.state === "CLAIMED") {',to:'if (row.state !== "CLAIMED") {'}, {id:'skip-receipt-validation',file:'local.js',from:'throw new Error("Malformed receipt envelope.");',to:'return envelope.value;'}, {id:'missing-truth-accepted',file:'local.js',from:'if (!Object.prototype.hasOwnProperty.call(observation, "result")) {',to:'if (false && !Object.prototype.hasOwnProperty.call(observation, "result")) {'}, ]; diff --git a/examples/summa42-host-integration/ACCEPTANCE.md b/examples/summa42-host-integration/ACCEPTANCE.md new file mode 100644 index 00000000..834e8f9f --- /dev/null +++ b/examples/summa42-host-integration/ACCEPTANCE.md @@ -0,0 +1,67 @@ +# Summa42 #35 acceptance and original-versus-protected evidence + +Target: https://github.com/SofiaFlux/summa42/issues/35. Once base source is +8381734a9be368138546121ae708ec9c55be8e89. The original eight-scenario evidence +was preserved in local commit 407be3d33a0cff9f30c436c3fd9133178561b0b4 before +implementation. The reused storage/kernel work is from draft PR #303 head +ae906f64fa6c532d8f2052f4d13d9f13002fa077. Public Summa42 services are pinned at +efc6794ea79a4be5b2f60e376833a1d03cba053c. Exact final commits, package hashes, +test exits, CI and posting/release status are in `validation.json`. + +## RED → GREEN + +| Protection requirement | Original legacy configuration | Explicit admitted host configuration | +| --- | --- | --- | +| T06: same real intent, fresh planner operation ID | Two committed effects; maximum-one invariant fails RED | H01: IDENTITY_CONFLICT; original intent remains one effect. Invented unadmitted slot is INTENT_REQUIRED. A separately authorized identical intent has its own distinct admitted ID and one effect. | +| T07: schema-valid empty replacement authority | Two committed effects; maximum-one invariant fails RED | H02: CONTINUITY_LOST for empty/partial history with current metadata, locally rehashed replacement, and rewound metadata; no second provider POST. Restoration of the exact original history permits replay, still one effect. H12: worker-local checkpoint replacement has no authority over the independently held execution history. | +| Reservation followed by pre-effect crash | The old proof did not qualify this host gate | H04: actual worker kill; zero effects, persisted UNKNOWN, no guessed confirmation or redispatch. H05: admission alone creates no execution record; a worker killed before claim can later execute once. | + +The independent checker deliberately fails on the original baseline. The old +eight-scenario tests are retained; their known two-effect limits do not count as +protection success. New H01/H02 assertions require denial **and** independent +provider-effect counts. Read original raw results alongside the protected run. A separate revision-only partial-restore RED control also records two effects under the same operation ID after operation-row loss while current metadata is retained; this motivated the independently state-bound v2 checkpoint. + +## Issue criterion mapping + +| #35 criterion | Verified implementation/evidence | Boundary | +| --- | --- | --- | +| Stateful TwinProvider with dropped/delayed acknowledgements | Existing order twin fsyncs a provider-owned journal, never deduplicates and never reads Once state. Original raw control creates two effects. H03 drops socket acknowledgement after commit; retained registered-action tests exercise delayed/held acknowledgement. | Controlled loopback provider, not a commercial provider or network conformance certification. | +| OUTCOME_UNKNOWN never grants retry permission | H03/H04/H08 and actual Go recovery: no second POST, UNKNOWN is durable; ABSENT, lookup failure, incomplete/stale/forged receipt remain non-authorizing. | No liveness guarantee without authoritative positive truth. | +| Reconciliation before replacement Attempt/planner progress | The actual reference host calls Summa42's real Reconciler **before** StartAttempt. Three fresh Go recovery processes retain the original Attempt and unresolved exposure; direct SQLite inspection proves only one Attempt existed while blocked. After exact reconciliation, replacement Attempt creation proceeds. | This is the configured reference task entrypoint. Full Box/LLM/global scheduler wiring and unrelated commitments are not qualified. Deploying that host-wide gate remains an integration responsibility. | +| Non-idempotent commit, ack drop, worker kill, replacement, one unresolved effect | H03 kills an actual MCP worker. The actual service proof kills the Go worker while Summa42's durable operation remains DISPATCHED; both proofs retain exactly one provider effect/POST through repeated fresh processes and reconcile the exact receipt. | Same-machine processes and two local PostgreSQL clusters. No physical-host, multi-region or arbitrary split-brain guarantee. | +| Logical identity persists across restarts/replanning | Immutable scope/key → host-generated operation ID mapping, complete effect fingerprint, original execution authority/witness. H01/H07/H12; actual Summa42 Task/effect-slot persistence in SQLite across worker loss. | Semantic identity must come from trusted host/task admission, never inferred from equal payloads. | + +## Adversarial coverage + +| Scenario | Required observation | +| --- | --- | +| H01 | Fresh-ID substitution and unadmitted slot denied; separately authorized identical action succeeds. | +| H02 | Empty/rewound execution history denied by independent witness; exact-history recovery replays. | +| H03 | Commit, dropped acknowledgement, SIGKILL, repeated workers, UNKNOWN, exact reconciliation and replay; one effect. | +| H04 | SIGKILL after reservation before provider call; UNKNOWN with zero effects. | +| H05 | Kill after intent admission before execution claim; no false receipt; later single first execution. | +| H06 | Six simultaneous workers; only one committed effect, with IN_FLIGHT or exact confirmation for others. | +| H07 | SKU/quantity/destination/resource/account/tool drift conflicts before dispatch. Generic SDK tests separately bind amount/other effect fields. | +| H08 | Socket-failed lookup, ABSENT, UNKNOWN, stale/non-authoritative/incomplete/mismatched receipt never confirm. | +| H09 | Missing, rewound or contradictory checkpoint, unavailable execution storage and unavailable witness all fail closed; trustworthy recovery succeeds. | +| H10 | Corrupt/missing immutable intent; runtime insertion, update, deletion, truncation and DDL denials; no implicit repair. | +| H11 | Changed trusted admission conflicts; missing/ambiguous/noncanonical key and malformed admission refuse execution. | +| H12 | Valid worker-local checkpoint replacement cannot change shared execution authority. | +| Existing regression | Hidden retries inside an opaque callback remain an explicit limitation; the qualified adapter issues one unary create. Contradictory concurrent authoritative results now CONFLICT while preserving the winning receipt. | + +## Evidence tiers and release gate + +The package separates Once-local kernel/MCP tests, reference-host conformance, +actual pinned Summa42 service results, and full-system/deployment claims. A +checker importing only Node built-ins recounts journals and reads the actual +Summa42 SQLite database; it is independent of SDK-reported success, **not** an +external evaluator's certification. Source candidates and green tests do not +prove package publication or upstream adoption. + +No live payment, production order/refund, production credentials or customer data +were used. Qualified source is suitable for review in a branch/PR. Release and +merge require current-head checks, repository review/protection compliance and +resolution of material findings. Real deployment bindings, TLS, operational +restore/RTO/RPO, independent physical domains and global scheduler conformance +remain unqualified. Do not publish a production-ready or universal exactly-once +claim from these results. diff --git a/examples/summa42-host-integration/EVIDENCE-PACKAGE.md b/examples/summa42-host-integration/EVIDENCE-PACKAGE.md new file mode 100644 index 00000000..fa537684 --- /dev/null +++ b/examples/summa42-host-integration/EVIDENCE-PACKAGE.md @@ -0,0 +1,106 @@ +# Silent Chicken — independently runnable qualification + +This package contains unpublished source-built SDK 0.1.27 / MCP 0.2.3 tarballs, +the original eight-scenario baseline, twelve stronger host-admission scenarios, +a checker that imports no Once/MCP code, an actual pinned Summa42 service fixture, +raw results and an engineering/security/migration report. No real payments, +production accounts or commercial-provider actions are involved. + +## Setup and run + +Requires Node 24.15+ (qualified locally on 24.19.0), npm and two disposable +PostgreSQL instances. Docker Compose is one reproducible setup option; it is not +required if you already have isolated local test instances. The two fixture +clusters still share your physical machine, so this does not certify independent +physical failure/restore domains. First-time npm installation needs network. + +```sh +npm ci --ignore-scripts --no-audit --no-fund +docker compose up -d --wait +``` + +Use these **synthetic local fixture** connection settings (never replace them +with production databases). Bash: + +```sh +export ONCE_TEST_POSTGRES=postgresql://once_fixture:fixture-only-ci@127.0.0.1:54369/postgres +export ONCE_TEST_WITNESS=postgresql://once_fixture:fixture-only-ci@127.0.0.1:54370/postgres +npm test +``` + +PowerShell: + +```powershell +$env:ONCE_TEST_POSTGRES='postgresql://once_fixture:fixture-only-ci@127.0.0.1:54369/postgres' +$env:ONCE_TEST_WITNESS='postgresql://once_fixture:fixture-only-ci@127.0.0.1:54370/postgres' +npm test +``` + +The tests explicitly create and remove their own randomly named fixture +databases/roles. They never initialize a missing production authority. The script +prints a fresh run directory containing provider journal/counters, event traces, +authority snapshots, outcomes, latency samples and available SQL/memory metrics. + +```sh +npm run verify -- protected silent-chicken-runs/run-PRINTED_NAME +``` + +For the original vulnerable legacy configuration: + +```sh +npm run test:baseline +npm run verify -- baseline summa42-runs/run-PRINTED_NAME +``` + +The second command is **expected to fail RED**: it requires a maximum of one +effect and observes two for baseline T06 and T07. The old baseline itself retains +all eight assertions, including its explicit counterexamples. In the new +protected profile, H01/H02 require one effect and deny identity/history +substitution. A separately host-admitted identical action still executes. + +`vendor/` contains both exact Once candidates; `package-lock.json` pins public +dependencies and explicitly overrides the legacy MCP SDK pin with the qualified +SDK candidate. The override is an opt-in deployment choice. Installing MCP alone +with its unchanged published SDK 0.1.25 dependency does not enable this profile; +the capability is denied before provider execution. Legacy packed-consumer +compatibility is qualified separately. + +## Actual Summa42 service proof + +Follow `ACTUAL-SUMMA42.md` to clone/check out public Summa42 commit +`efc6794ea79a4be5b2f60e376833a1d03cba053c`, copy `reference/main.go` into its +`cmd/once-evidence/main.go`, and build using Go 1.27.1. The full source repository +is not embedded or modified upstream. Set `ONCE_SUMMA42_BINARY` to that executable +and `ONCE_SUMMA42_REVISION` to the pinned commit, then: + +```sh +npm run test:summa42-host +``` + +The reference adapter exercises actual Summa42 operation/execution/resource/ +SQLite services in fresh processes and kills the Go worker after commit. It does +not run the Box/LLM/global scheduler. The bridge token and provider credentials +are ephemeral fixture data, never tool arguments or saved evidence. + +## Read the evidence + +- `ENGINEERING.md`: implementation, architecture diagram, protocol, security, + trust boundaries, compatibility and migration. +- `ACCEPTANCE.md`: Summa42 #35 criteria and scenario mapping. +- `validation.json`: exact commands, versions, exits, failures and release gates. +- `qualification/`: final local tests, actual service proof and original RED run. +- `performance.json`: measured fixture latency, logical SQL operations and memory; + physical disk I/O, cloud prices, production throughput and HA are not inferred. +- `provenance.json`, `source/`, `SHA256SUMS.json`: source/build identity and file + integrity. These are reproducibility metadata, not third-party certification. +- `GITHUB-RESPONSE.md`: factual maintainer update; posting status is recorded in + `validation.json` rather than assumed from the presence of a draft. + +Do not reset ledgers, mint IDs or infer success to unblock UNKNOWN. Without +authoritative provider truth, an abandoned reservation may remain UNKNOWN with +zero provider effects indefinitely. Production host credentials, verified TLS, +physical restore-domain separation, operational recovery and full scheduler gates +remain deployment responsibilities. No universal exactly-once claim is made. + +When finished, `docker compose down -v` removes only these disposable Compose +fixture volumes. Preserve the printed evidence directories for independent review. diff --git a/examples/summa42-host-integration/README.md b/examples/summa42-host-integration/README.md new file mode 100644 index 00000000..bc0411a1 --- /dev/null +++ b/examples/summa42-host-integration/README.md @@ -0,0 +1,37 @@ +# Actual Summa42 service integration qualification + +`main.go` is a deliberately narrow reference provider and service fixture. It is built **inside** public Summa42 revision `efc6794ea79a4be5b2f60e376833a1d03cba053c`, because Summa42's operation/execution packages are Go `internal` packages. It runs real `operations.Service`, `execution.Service`, resource reservation/settlement, policy/admission and SQLite persistence in separate Go processes. It does not launch Box, an LLM, the global scheduler or the full planner. + +The existing Summa42 host creates a durable Task/effect slot, separate from an Attempt. A trusted operator then admits that exact task/slot to Once. The reference bridge pins the original Summa42 operation ID, task ID, effect slot, canonical intent/fingerprint/revision, fixed provider account and complete order. Worker input cannot create another admission or choose a different slot. The bridge has a private loopback token held by the host; it is never an MCP tool argument. New real intents require separate trusted admission. + +The driver calls the existing Once registered MCP action; it does not add an execution state machine or duplicate the provider receipt validator. A separate MCP recovery host refuses provider creates, so Summa42's `LookupOutcome` cannot turn a missing execution record into an effectful request. Missing Summa42 state is rejected before calling its initializing SQLite opener. + +## Reproduction + +First build/install the candidate Once SDK and MCP and supply **disposable**, separately provisioned execution and witness PostgreSQL connection settings as `ONCE_TEST_POSTGRES` and `ONCE_TEST_WITNESS`. Do not print production credentials. The evidence package includes a loopback-only Docker Compose fixture as an alternative to existing disposable local PostgreSQL instances. + +```sh +git clone https://github.com/SofiaFlux/summa42.git /absolute/disposable/summa42 +git -C /absolute/disposable/summa42 checkout efc6794ea79a4be5b2f60e376833a1d03cba053c +mkdir -p /absolute/disposable/summa42/cmd/once-evidence +cp examples/summa42-host-integration/main.go /absolute/disposable/summa42/cmd/once-evidence/main.go +cd /absolute/disposable/summa42 +GOCACHE=/tmp/summa42-full-go-cache go test ./internal/operations/... ./internal/execution/... -count=1 -timeout 5m +GOCACHE=/tmp/summa42-full-go-cache go build -o /absolute/disposable/summa42-once-fixture ./cmd/once-evidence +``` + +Use Go 1.27.1. On Windows set `GOCACHE` and `GOPATH` to writable workspace directories, use an `.exe` output filename, and use PowerShell copy/directory commands. Then from Once: + +```sh +ONCE_SUMMA42_BINARY=/absolute/disposable/summa42-once-fixture \ +ONCE_SUMMA42_REVISION=efc6794ea79a4be5b2f60e376833a1d03cba053c \ +node mcp/scripts/summa42-host-proof.mjs /absolute/evidence-output +``` + +PowerShell: set the two variables with `$env:ONCE_SUMMA42_BINARY` / `$env:ONCE_SUMMA42_REVISION`, then run the same Node script with a Windows output path. + +The proof initializes a new disposable Summa42 database, admits its actual slot, commits one provider effect, drops acknowledgement and kills the Go worker while Summa42 still records `DISPATCHED`. Three new Go recovery processes remain blocked before replacement Attempt creation and retain `OUTCOME_UNKNOWN` / `UNRESOLVED` exposure. A read-only reconciliation gate runs before StartAttempt. Authoritative read-only reconciliation produces `CONFIRMED_EFFECT` / `SETTLED`; a further process replays without another provider read or write. The fsynced provider journal is independent of both Once and Summa42 ledgers. + +The driver saves events, source/binary identity, the complete disposable Summa42 database and provider journal. Fixture database roles in this particular integration driver are privileged disposable principals; separate hardening tests qualify restricted runtime/admission/witness roles. No credential, actual customer data or production state should be added to an upstream contribution. + +These are actual pinned Summa42 service results through a new reference provider, **not** an upstream-merged integration, full issue closure, scheduler-wide progression gate or production qualification. Replacing both independent histories and trusted host pins coherently, hostile administrators and compromised host credentials remain outside this proof. diff --git a/examples/summa42-host-integration/compose.yaml b/examples/summa42-host-integration/compose.yaml new file mode 100644 index 00000000..9c828edf --- /dev/null +++ b/examples/summa42-host-integration/compose.yaml @@ -0,0 +1,24 @@ +name: once-silent-chicken-fixture +services: + execution: + image: postgres:18.6 + environment: + POSTGRES_USER: once_fixture + POSTGRES_PASSWORD: fixture-only-ci + ports: ['127.0.0.1:54369:5432'] + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U once_fixture'] + interval: 3s + timeout: 3s + retries: 20 + witness: + image: postgres:18.6 + environment: + POSTGRES_USER: once_fixture + POSTGRES_PASSWORD: fixture-only-ci + ports: ['127.0.0.1:54370:5432'] + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U once_fixture'] + interval: 3s + timeout: 3s + retries: 20 diff --git a/examples/summa42-host-integration/main.go b/examples/summa42-host-integration/main.go new file mode 100644 index 00000000..a30bcd3e --- /dev/null +++ b/examples/summa42-host-integration/main.go @@ -0,0 +1,189 @@ +// Controlled integration fixture, copied INSIDE the pinned Summa42 checkout. +// It runs actual operations/execution/resource services, not an LLM or Box CLI. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/SofiaFlux/summa42/internal/approvals" + "github.com/SofiaFlux/summa42/internal/domain" + "github.com/SofiaFlux/summa42/internal/execution" + "github.com/SofiaFlux/summa42/internal/operations" + "github.com/SofiaFlux/summa42/internal/policy" + "github.com/SofiaFlux/summa42/internal/purpose" + "github.com/SofiaFlux/summa42/internal/resources" + state "github.com/SofiaFlux/summa42/internal/state/sqlite" + "github.com/SofiaFlux/summa42/internal/testutil" +) + +type allowFixturePolicy struct{} + +func (allowFixturePolicy) Evaluate(_ context.Context, in policy.PolicyInput) (domain.PolicyDecision, error) { + return domain.PolicyDecision{ID: domain.NewID("decision"), Outcome: domain.PolicyAllow, + PolicySetID: "fixture-policy", PolicySetHash: "fixture-v1", PolicyCapabilitiesHash: "fixture-caps", + InputDigest: "fixture-input", EvaluatedAt: in.Now}, nil +} + +type bridgeProvider struct{ url, token string } + +func (p bridgeProvider) Name() string { return "once-orders" } +func (p bridgeProvider) Capability() string { return "external:once-orders" } +func (p bridgeProvider) EnforcementLevel() domain.EnforcementLevel { return domain.EnforcementEnforced } +func (p bridgeProvider) AdapterVersion() string { return "once-reference-v1" } +func (p bridgeProvider) AdapterVersionSemanticallyRelevant() bool { return true } +func (p bridgeProvider) CanonicalIntent(d operations.IntentDescriptor) ([]byte, error) { + v, ok := d.(testutil.PurchaseIntent) + if !ok || v.SKU != "SKU-1" || v.Quantity != 2 { + return nil, errors.New("unsupported fixture intent") + } + return json.Marshal(v) +} +func (p bridgeProvider) CostProfile(d operations.IntentDescriptor) (operations.CostProfile, error) { + if _, err := p.CanonicalIntent(d); err != nil { + return operations.CostProfile{}, err + } + return operations.CostProfile{MaxExposure: 20, Enforceability: resources.Enforceability{CostControl: resources.CostTechnicallyCapped, RequireHardCap: true, Source: "controlled-local-order-fixture"}}, nil +} +func (p bridgeProvider) request(ctx context.Context, mode string, r operations.ProviderDispatchRequest) (operations.ProviderOutcome, error) { + body, err := json.Marshal(r) + if err != nil { + return operations.ProviderOutcome{}, err + } + req, err := http.NewRequestWithContext(ctx, "POST", p.url+"/"+mode, bytes.NewReader(body)) + if err != nil { + return operations.ProviderOutcome{}, err + } + req.Header.Set("Authorization", "Bearer "+p.token) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect refused") }} + response, err := client.Do(req) + if err != nil { + return operations.ProviderOutcome{State: domain.OperationOutcomeUnknown}, domain.ErrOutcomeUnknown + } + defer response.Body.Close() + var out struct { + Status string `json:"status"` + Result struct { + ProviderReference string `json:"providerReference"` + } `json:"result"` + } + if response.StatusCode != 200 || json.NewDecoder(response.Body).Decode(&out) != nil || out.Status != "CONFIRMED" || out.Result.ProviderReference == "" { + return operations.ProviderOutcome{State: domain.OperationOutcomeUnknown}, domain.ErrOutcomeUnknown + } + return operations.ProviderOutcome{State: domain.OperationConfirmedEffect, ProviderReference: out.Result.ProviderReference, ActualCost: 20}, nil +} +func (p bridgeProvider) Dispatch(ctx context.Context, r operations.ProviderDispatchRequest) (operations.ProviderOutcome, error) { + return p.request(ctx, "dispatch", r) +} +func (p bridgeProvider) LookupOutcome(ctx context.Context, r operations.ProviderDispatchRequest) (operations.ProviderOutcome, error) { + return p.request(ctx, "lookup", r) +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func run() error { + if os.Getenv("ONCE_FIXTURE_ONLY") != "disposable-summa42" { + return errors.New("explicit disposable fixture admission required") + } + home, mode := os.Getenv("ONCE_SUMMA42_FIXTURE"), os.Getenv("ONCE_SUMMA42_STAGE") + if home == "" || !filepath.IsAbs(home) { + return errors.New("absolute disposable fixture directory required") + } + ctx := context.Background() + dbpath := filepath.Join(home, "summa42.sqlite") + if mode != "init" { + if _, err := os.Stat(dbpath); err != nil { + return errors.New("original Summa42 history missing; recovery cannot bootstrap") + } + } + if mode == "init" { + if _, err := os.Stat(dbpath); err == nil { + return errors.New("fixture already initialized") + } + } + store, err := state.Open(ctx, dbpath) + if err != nil { + return err + } + defer store.DB().Close() + clk := testutil.NewClock(time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)) + exec := execution.New(store, clk, purpose.New(store, clk)) + ledger := resources.New(store, clk) + provider := bridgeProvider{url: os.Getenv("ONCE_BRIDGE_URL"), token: os.Getenv("ONCE_BRIDGE_TOKEN")} + svc := operations.New(store, clk, exec, allowFixturePolicy{}, ledger, approvals.New(store, clk), "collective-once-fixture", provider) + const taskID domain.ID = "task-once-fixture" + emit := func(op domain.ExternalOperation, attemptID domain.ID, gate string) error { + var reservation string + if err := store.DB().QueryRowContext(ctx, `SELECT state FROM resource_reservations WHERE reservation_id=?`, op.ReservationID).Scan(&reservation); err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{"pid": os.Getpid(), "attemptId": attemptID, "taskId": taskID, "operationId": op.ID, "effectSlotId": op.EffectSlotID, "state": op.State, "reservation": reservation, "providerReference": op.ProviderReference, "hostGate": gate}) + } + var attempt domain.Attempt + if mode == "init" { + now := clk.Now().UTC().Format(time.RFC3339Nano) + if _, err := store.DB().ExecContext(ctx, `INSERT INTO policy_sets(policy_set_id,version,module_name,module,policy_hash,capabilities_hash,active,created_at) VALUES('fixture-policy',1,'fixture.rego','package fixture','fixture-v1','fixture-caps',1,?)`, now); err != nil { + return err + } + if _, err := store.DB().ExecContext(ctx, `INSERT INTO resource_envelopes(envelope_id,hard_limit,created_at) VALUES('fixture-envelope',1000,?)`, now); err != nil { + return err + } + if _, err := store.DB().ExecContext(ctx, `INSERT INTO tasks(task_id,purpose_kind,purpose_id,state,current_fence,acceptance_criteria_json,required_capabilities_json,required_enforcement,authority_ceiling_json,resource_envelope_id,priority,created_at,updated_at) VALUES(?,'OWNER_DIRECTIVE','owner-fixture','ELIGIBLE',0,'[]','[]','ENFORCED','["external:once-orders"]','fixture-envelope',0,?,?)`, taskID, now, now); err != nil { + return err + } + attempt, err = exec.StartAttempt(ctx, taskID, "once-fixture", time.Hour) + } else { + var previous string + if err := store.DB().QueryRowContext(ctx, `SELECT current_attempt_id FROM tasks WHERE task_id=?`, taskID).Scan(&previous); err != nil { + return err + } + if mode == "dispatch" { + // Dispatch uses the existing live attempt from initialization. + attempt.ID = domain.ID(previous) + } else { + // Reference host progression gate: a replacement PROCESS may reconcile, + // but may not create another Attempt while this intent is unresolved. + var priorID domain.ID + if err := store.DB().QueryRowContext(ctx, `SELECT o.operation_id FROM external_operations o JOIN effect_slots e ON e.effect_slot_id=o.effect_slot_id WHERE e.task_id=? AND e.trusted_slot_key='owner-admitted-order-slot' ORDER BY o.operation_sequence DESC LIMIT 1`, taskID).Scan(&priorID); err != nil { + return err + } + prior, reconcileErr := operations.NewReconciler(svc).Reconcile(ctx, priorID) + if reconcileErr != nil && !errors.Is(reconcileErr, domain.ErrOutcomeUnknown) { + return reconcileErr + } + if prior.State != domain.OperationConfirmedEffect { + return emit(prior, domain.ID(previous), "BLOCKED_UNRESOLVED") + } + if err := exec.RevokeLease(ctx, domain.ID(previous)); err != nil { + return err + } + attempt, err = exec.StartAttempt(ctx, taskID, "replacement-"+fmt.Sprint(os.Getpid()), time.Hour) + } + } + if err != nil { + return err + } + op, err := svc.Prepare(ctx, operations.PrepareRequest{AttemptID: attempt.ID, Provider: provider.Name(), TrustedSlotKey: "owner-admitted-order-slot", Intent: testutil.PurchaseIntent{SKU: "SKU-1", Quantity: 2}, Risk: "LOW"}) + if err != nil { + return err + } + if mode != "init" { + op, err = svc.Dispatch(ctx, op.ID, attempt.ID) + if err != nil && !errors.Is(err, domain.ErrOutcomeUnknown) { + return err + } + } + return emit(op, attempt.ID, "OPEN") +} diff --git a/examples/summa42-hostile-retry/EVIDENCE.md b/examples/summa42-hostile-retry/EVIDENCE.md new file mode 100644 index 00000000..dc874bb0 --- /dev/null +++ b/examples/summa42-hostile-retry/EVIDENCE.md @@ -0,0 +1,89 @@ +# Once / Summa42 #35 evidence report + +Prepared 20 October 2026. Target: [SofiaFlux/summa42 #35](https://github.com/SofiaFlux/summa42/issues/35), fetched with all comments (none) on this date. This is a Once-side conformance demonstration, not an implementation or test run inside Summa42. No third-party independent evaluator has reviewed it. + +## Finding and claim boundary + +With the same canonical operation identity, complete effect binding, original durable ledger and reviewed host-owned action, Once suppresses a second provider write after commit, acknowledgement loss, abrupt worker termination and replacement attempts. It conservatively blocks unresolved outcomes, then recovers the exact receipt through authoritative read-only provider lookup and persists replay. + +The package **does not satisfy the whole of #35 by itself**. Once does not prevent a planner from inventing a new logical ID, does not gate unrelated planner progress, and the tested local registered action cannot recognize a schema-valid replacement ledger across separate process lifetimes. Those controls belong to the host / shared execution authority and need a Summa42 integration. No universal exactly-once guarantee is claimed. + +## Audit and reuse + +Base source: `8381734` (full revision in `provenance.json`), fetched from Once `origin/main`. Source SDK reports 0.1.26, while `docs/published-versions.json` identifies SDK 0.1.25 and MCP 0.2.1 as public versions. The standalone package bundles local builds of SDK 0.1.26 and MCP 0.2.1; it explicitly overrides MCP's declared SDK 0.1.25 dependency. These are exact local build qualifications, not proof of registry publication. A separate published-SDK consumer and existing packed-consumer regression are identified in validation records. + +Reused infrastructure: + +| Existing component | Audit finding / reuse | +| --- | --- | +| `sdk/typescript/src/local.ts` | Atomic SQLite claim; durable `CLAIMED`, `UNKNOWN`, `CONFIRMED`; live lease is `IN_FLIGHT`; expired claim becomes UNKNOWN; no redispatch even on ABSENT; complete payload fingerprint conflict; durable replay. The low-level opener creates missing state and is not a state-continuity guard. | +| `sdk/typescript/src/tool-call.ts` | Explicit developer-supplied operation ID; frozen complete effect; transport metadata excluded; strict reconciliation envelope. It trusts the host's provider-truth adapter. | +| `mcp/src/registered-action.ts` | Existing narrow local-test order action; host-owned provider/account/resource/tool binding; strict input, admission and receipt schemas; fresh complete authoritative evidence must match operation ID and full prepared effect. No generic connector execution. | +| `mcp/src/existing-ledger.ts` | Existing-only startup admission and schema checks; fail closed for missing/empty/corrupt state and schema loss; live file identity checking. It does not pin an independently stored ledger identity across host restarts. | +| `mcp/test/registered-action.test.mjs` | Existing actual MCP/process/provider coverage, drift, bad truth, authority drift, malicious inputs, state loss and crash. Existing crash test expires a lease by editing SQLite. All assertions preserved. | +| `mcp/test/fixtures/order-provider.mjs` | Stateful loopback twin, every accepted POST fsyncs an independent receipt, no idempotency key enforcement or deduplication. Existing `hold` and `lost-ack` modes retained. Added commit/drop observer hooks and explicit lookup socket failure injection. | +| `mcp/test/fixtures/order-host.mjs` | Existing stdio host and production registration reused. Added optional test-only barrier after actual socket acknowledgement loss, before returning to Once. Parent kills the process at this barrier. Ordinary fixture behavior is unchanged. | +| `examples/first10/prove.mjs`, SDK tool-call/local/wrap/MCP-boundary tests | Audited existing proof/API coverage; used existing suites and packed/published consumer checks, rather than creating another execution state machine. | +| `sdk/typescript/src/connect/mcp-execution-boundary.ts` | Audited transport IDs excluded from logical identity and locally reviewed identity/effect selectors. This alternate boundary is covered by existing regressions; the new canonical proof uses the narrower registered action. | + +No production SDK or MCP execution semantics were changed. New work is a focused test driver, independent evidence checker, packaging instructions and optional fixture instrumentation. + +## Canonical scenario and observable evidence + +T02 durably saves `task-35:order:1` and the complete order before dispatch. A child host executes one HTTP POST. The provider appends and fsyncs its receipt, destroys the socket, and the worker observes the failed acknowledgement at a test barrier. The parent checks the separate provider journal and SQLite `CLAIMED` row, kills the worker with `SIGKILL`, waits for the closed call, and launches replacement hosts. + +Replacement Attempt 2 has a new process, client name, session label and actual JSON-RPC request ID; it reuses the saved operation ID and ledger. The live lease returns `IN_FLIGHT`. The test waits for the real 30-second lease deadline without changing SQLite timestamps or state. Three unresolved calls and a further process/replanning handoff remain `UNKNOWN` with `retryAllowed:false` and **one POST / one provider effect**. + +The test then compares a read-only provider GET against the separately read fsynced journal. The registered reconciliation adapter validates the exact receipt; bounded re-observation permits only UNKNOWN or final CONFIRMED, never another write. A further fresh host replays the confirmed receipt without lookup or dispatch. Events record the order, process IDs, distinct attempt/session IDs, actual request IDs, outcomes and final ledger row. + +Attempt/session UUIDs are harness labels, not Summa42 domain objects. Replanning is simulated by a fresh caller loading the saved intent. The provider runs outside the worker in the test driver, owns a distinct journal and never reads the Once ledger; it is an independently observable local twin, not a real commercial provider or independent organization. + +## Acceptance mapping + +The AC labels below are local report identifiers, in the order of #35's five acceptance bullets. + +| Criterion | Test / evidence | Demonstration and limit | +| --- | --- | --- | +| AC1: stateful twin simulates remote effects and dropped/delayed acknowledgements | T01, T02; `provider.jsonl`, commit/drop events; existing registered-action `hold` tests | Stateful non-deduplicating twin, durable commit and explicit dropped socket; raw repeat creates two effects. Existing regressions exercise delayed acknowledgement. The canonical new test covers dropped acknowledgement, not arbitrary network behavior. | +| AC2: OUTCOME_UNKNOWN grants no retry permission | T02, T03, T04, T05 | Actual Once `UNKNOWN` / `retryAllowed:false`, no added write. IN_FLIGHT is also blocked. Once's error name is UNKNOWN; this report does not rename Summa42's state machine. | +| AC3: require external reconciliation before replacement Attempt or planner iteration proceeds | T02, T04, T08; negative limits T06/T07 | **Partial:** the same protected operation cannot redispatch while unresolved; positive lookup recovers its receipt. Replacement hosts can start to read/block/reconcile. Global scheduler/planner progress, unrelated operations, new-ID admission and cross-restart ledger continuity are not implemented here. | +| AC4: non-idempotent dispatch, ack loss, worker kill, replacement, count exactly one unresolved | T02; ordered events, journal, counters, claim/final ledger snapshots | Direct canonical sequence with actual child kill and real lease expiration; exactly one provider POST and one effect. T01 proves the twin itself does not suppress duplicates. | +| AC5: logical effect persists through restart/replanning | T02, T03, T06, T07, T08 | Saved canonical ID, effect fingerprint and original SQLite persist across several fresh hosts; complete effect drift conflicts, confirmation replays. Requires trusted host identity/ledger preservation. Planner-generated replacement IDs and valid replacement ledgers are observed counterexamples. | + +## Scenario matrix + +| Test | Expected result | Independent final effects / POSTs | +| --- | --- | --- | +| T01 | Raw lost-ack duplicate control | 2 / 2 | +| T02 | CLAIMED -> IN_FLIGHT -> UNKNOWN -> exact CONFIRMED -> fresh-host replay | 1 / 1 | +| T03 | Changed SKU, quantity, destination, tool, resource or verified account: CONFLICT | 1 / 1 | +| T04 | Socket-failed lookup, UNKNOWN, NOT_FOUND, ABSENT, malformed, stale, future, incomplete, non-authoritative or mismatched truth: UNKNOWN; unavailable provider rejects host admission | 1 / 1 | +| T05 | Missing/corrupt/empty ledger rejects startup; live and restarted schema loss is STATE_UNAVAILABLE / startup rejection; missing schema never recreated | 1 / 1 | +| T06 | Input identity injection rejected; original UNKNOWN blocks; arbitrary fresh ID with identical payload is accepted | **2 / 2: demonstrated limit** | +| T07 | Original ledger removed, explicitly provisioned valid empty ledger admitted after restart | **2 / 2: demonstrated limit** | +| T08 | Confirmed effect survives replacement; no GET or POST on exact replay; changed quantity conflicts | 1 / 1 | + +## Validation and failure history + +Final run paths, versions, commands and exit codes are in `validation.json` and the delivered run directories. Eight named scenario tests plus the enclosing Node test produce nine Node test passes. Expected limit tests are marked PASS because they assert the counterexample; they are not protection claims. + +Existing assertions: 71 SDK tests and 25 MCP tests passed on the source-built SDK combination. SDK ESM/CommonJS and MCP builds passed. FIRST 10 generation check and site surface guard passed. Packed SDK and clean published-consumer results are separately recorded; publication and whole-repository test coverage are not implied. + +Retained development failures: + +- Initial sandboxed execution could not reach loopback: all eight scenarios failed before useful provider execution. Raw control detected zero effects rather than treating a failed network call as success. Re-run with local loopback/process permission. +- The first loopback-enabled run passed seven scenarios but T02 returned conservative UNKNOWN where the test required immediate CONFIRMED on the first positive lookup. It still produced exactly one effect and one POST. The precise transient cause was not established. A subsequent run passed after adding an explicit independent GET/journal comparison. The final test allows at most five read-only reconciliation observations and records all outcomes, always preserving the same identity and never redispatching; it fails unless the exact receipt becomes CONFIRMED. This bounds the claim to observed recovery, not guaranteed first-observation availability. +- Initial dependency installs failed because network access/cache entries were unavailable; authorized network installation of locked dependencies resolved setup. No production credentials were used. + +Failed runs are retained as diagnostics, not counted as successful evidence. Read their `results.json`, TAP and events alongside the final run. No existing test was removed or weakened. + +## Unsupported / unimplemented guarantees + +- Summa42 scheduler, replanner, Attempt model, budget/exposure containment and integration are unimplemented in this package. +- The host must durably select/admit canonical IDs. A fresh arbitrary ID can create a duplicate real-world intent, and the current narrow registered action does not cross-check it against an external intent registry. +- A schema-valid empty ledger can be substituted between process lifetimes. Missing/corrupt/schema-lost state is rejected by the registered action, but this is not a cross-restart continuity witness. The low-level SDK alone also initializes missing state; do not generalize the registered action's guard to all SDK calls. +- Single-machine persistent SQLite only. No distributed execution, split-brain, filesystem rollback, malicious operator, storage hardware power-loss, or provider crash/restart guarantee is established. +- The host owns complete effect binding, authoritative provider lookup, admission and write routing. Hidden provider-client retries, raw bypass and incomplete adapters remain outside the demonstrated boundary. +- No liveness guarantee when truth is absent or unavailable. UNKNOWN remains blocked indefinitely. No real payment, commercial provider, Summa42 deployment, external adoption, independent security audit or universal exactly-once evidence is claimed. + +No merge, release, publication, deployment, PR or external comment was performed. diff --git a/examples/summa42-hostile-retry/GITHUB-RESPONSE.md b/examples/summa42-hostile-retry/GITHUB-RESPONSE.md new file mode 100644 index 00000000..ae3a5f0f --- /dev/null +++ b/examples/summa42-hostile-retry/GITHUB-RESPONSE.md @@ -0,0 +1,15 @@ +Prepared response for [Summa42 #35](https://github.com/SofiaFlux/summa42/issues/35). Not posted. Attach the evidence archive when sharing; no public artifact URL is asserted here. + +--- + +I've prepared a small reproducible Once-side test package against the hostile acknowledgement-loss scenario in this issue. + +It uses the existing Once registered MCP action, actual stdio worker processes and a stateful loopback provider with no native deduplication. Every accepted POST appends and fsyncs an independent provider journal. The raw control commits twice when blindly retried. + +The canonical test saves one logical operation ID, commits one provider effect, drops the HTTP acknowledgement, kills the worker at an observed loss barrier, then starts replacement callers with different attempt/session identities and actual JSON-RPC request IDs. With the original ID and ledger, retries remain blocked (IN_FLIGHT, then UNKNOWN), including a simulated replanning handoff. Provider count remains one. Authoritative read-only lookup recovers the exact receipt, and another process replays it without a write or lookup. + +The package includes eight scenarios, machine-readable results, journals, ordered traces, SQLite state, a separate checker with no Once/MCP imports, source provenance, setup commands and a criterion-by-criterion report. It also tests changed effect inputs, unavailable/invalid reconciliation, missing/corrupt/schema-lost state and confirmed replay across replacement. Final qualification details and retained development failures are in the report; the proof waits for the real lease deadline rather than editing SQLite time. + +There are two important counterexamples in the suite: a fresh arbitrary operation ID can create another effect for the same real-world intent, and a schema-valid empty ledger substituted between host lifetimes can also allow another write. The host must own canonical intent identity and preserve or independently attest the original execution authority. These cases are reported as limits, not suppressed by the test results. + +This demonstrates the retry-safety primitive under those conditions. It does not implement Summa42's scheduler/planner gate or establish the entire acceptance criterion about when replacement Attempts may proceed. The next integration step would be to bind Summa42's persisted External Operation identity to the protected boundary, gate planner progress on unresolved outcomes, and test ledger continuity in that host. UNKNOWN never grants permission to retry a write, mint a substitute ID or reset state. This is not a universal exactly-once claim. diff --git a/examples/summa42-hostile-retry/README.md b/examples/summa42-hostile-retry/README.md new file mode 100644 index 00000000..84b25aab --- /dev/null +++ b/examples/summa42-hostile-retry/README.md @@ -0,0 +1,52 @@ +# Once / Summa42 #35: bounded hostile-retry proof + +This package exercises the existing Once registered MCP action over actual stdio MCP and a deliberately non-idempotent loopback HTTP provider. It uses disposable local order records, never payments or production accounts. The provider appends and fsyncs a separate journal and never reads Once's ledger. + +## Run the standalone package + +Requires Node **24.15 or later** with `node:sqlite`, npm, local loopback sockets and permission to launch/terminate disposable child processes. Tested on Windows x64 / Node 24.19.0. Linux/macOS are unverified. No Docker, credentials or API accounts are required. Allow approximately 40 seconds: the crash test waits for the real 30-second claim lease. + +Extract the supplied archive, open a terminal in its directory, then: + +```sh +npm ci --ignore-scripts --no-audit --no-fund +npm test +``` + +`npm ci` downloads public dependencies using the included lockfile; Once SDK and MCP are supplied as local source-built tarballs in `vendor/`. First-time dependency installation needs network access. No registry publication occurs. The package is self-contained with respect to the Once checkout, not an offline Node/npm distribution. + +The runner prints a fresh `summa42-runs/run-...` directory containing `results.json`, `events.jsonl`, `execution.json`, TAP output, each provider journal and counters, and the disposable SQLite files. It never reuses prior test state. A failing assertion exits nonzero and leaves the evidence intact. + +Verify the printed directory using a separate checker with **no Once/MCP imports**: + +```sh +npm run verify -- summa42-runs/run-REPLACE_WITH_PRINTED_NAME +``` + +This checks recorded effect counts, acknowledgement-drop/kill ordering, distinct attempts/sessions/request IDs, blocked outcomes, reconciliation receipt equality and persisted confirmation. It checks evidence consistency; it is not a cryptographic attestation from a third party. Package SHA-256 values are in `provenance.json`, and delivered-file hashes are in `SHA256SUMS.json`. + +## Run from the existing Once repository + +Install locked dependencies and build both existing packages: + +```sh +npm --prefix sdk/typescript ci --ignore-scripts --no-audit --no-fund +npm --prefix sdk/typescript run build +npm --prefix mcp ci --ignore-scripts --no-audit --no-fund +npm --prefix mcp run build +npm --prefix mcp run test:summa42 +``` + +That route uses the MCP package's locked published SDK dependency (0.1.25). The delivered standalone qualification explicitly overrides it with the source-built SDK 0.1.26; both are labeled separately in the evidence. To rebuild the standalone source qualification: + +```sh +npm --prefix mcp run package:summa42 -- /absolute/path/to/a/new/output-directory +``` + +Then generate its lockfile with `npm install --package-lock-only --ignore-scripts --no-audit --no-fund`, run `npm ci --ignore-scripts --no-audit --no-fund`, and `npm test` in the new directory. On Windows supply an absolute Windows output path. Repository builds require Git and the locked development dependencies. `source/` contains audit snapshots; `provenance.json` identifies the base and exact built packages. The accompanying patch contains the new tests and fixture instrumentation. + +## Interpretation + +Eight scenarios pass when all expected assertions hold. **Two successful tests deliberately demonstrate duplicate effects outside the supported contract:** a caller substitutes a new logical ID, or an operator supplies a schema-valid empty replacement ledger after restart. Do not interpret the suite's PASS as protection against those cases. + +Read `EVIDENCE.md` for acceptance mapping, failure history and limits, and `GITHUB-RESPONSE.md` for the proposed, unposted maintainer response. UNKNOWN never authorizes another write. diff --git a/mcp/CHANGELOG.md b/mcp/CHANGELOG.md index 37063a74..4e26c1ab 100644 --- a/mcp/CHANGELOG.md +++ b/mcp/CHANGELOG.md @@ -1,5 +1,12 @@ # Unreleased +- Prepare MCP 0.2.3's opt-in trusted host intent admission capability. The + existing registered order action may use a separately admitted intent and + shared execution authority instead of worker-local SQLite. Missing capability + on the pinned legacy SDK fails closed; the evidence profile explicitly selects + the qualified SDK 0.1.27 candidate. Default helper and legacy SDK pin remain + unchanged. This source candidate is not a publication claim. + - Update the declared MCP client dependency from 2.0.0 to 2.3.1, removing the affected OAuth client version in GHSA-6qxp-vccf-f47h. Once's inspected runtime is a server/stdio boundary, which the advisory excludes; no HTTP OAuth diff --git a/mcp/COMPATIBILITY.md b/mcp/COMPATIBILITY.md index 2e06d8a0..67e69ccd 100644 --- a/mcp/COMPATIBILITY.md +++ b/mcp/COMPATIBILITY.md @@ -1,6 +1,11 @@ # MCP compatibility and execution authority This is the authoritative compatibility matrix for the public MCP package. +The Silent Chicken source candidate (MCP 0.2.3 / SDK 0.1.27) adds an explicitly +configured host-intent/independent-continuity profile. It is separately qualified +and does not upgrade the legacy configurations below. See +[`SILENT_CHICKEN.md`](../docs/SILENT_CHICKEN.md) for trust, dependency selection, +migration and release boundaries. No candidate publication is implied. The verified public npm/Registry MCP is **0.2.1** and SDK is **0.1.25**. Published site versions are recorded in `docs/published-versions.json` after independent verification. | Surface | Version / pin | Node minimum | What installation provides | diff --git a/mcp/REGISTERED_ACTION.md b/mcp/REGISTERED_ACTION.md index bf86b69b..caaac0db 100644 --- a/mcp/REGISTERED_ACTION.md +++ b/mcp/REGISTERED_ACTION.md @@ -200,3 +200,21 @@ valid-looking rollback or deletion. UNKNOWN blocking assumes history is retained # Existing-ledger admission repair Registration now uses a non-mutating admission guard before the SDK session is opened, holding a SQLite startup transaction across that open. It validates the existing operations table, columns, state constraint, basic row validity and SQLite integrity, then verifies file identity and schema version again after opening. Every tool call rechecks admission; a failure permanently invalidates that host session. Missing, empty, corrupt or schema-lost ledgers are not initialized or repaired. The original SDK remains 0.1.25; this guard protects the opt-in MCP boundaries, not every direct SDK caller. Explicit fresh-install provisioning remains separate. Valid-looking row deletion or ledger rollback cannot be detected solely from the remaining SQLite file and is outside this corruption-detection guarantee. +# Opt-in host-intent candidate + +The Silent Chicken candidate adds `intentAuthority` as an explicit alternative +to `statePath`. The host supplies a qualified execution authority, pinned scope +and authenticated-context `resolveKey` callback. No model argument may create +admissions or select scope. Caller operation IDs must equal the pre-admitted +canonical identity; complete effect drift conflicts. The matching SDK capability +is required: requesting this mode on the legacy published SDK is denied, never +silently downgraded to local SQLite. The default helper catalog remains unchanged. + +The required contract is `once-host-intent-v2`: the independent witness binds +both revision and the canonical content of operation and intent rows. A v1 +revision-only authority is denied in this mode. Deployments must explicitly +provision schema v2 on both stores; runtime calls never seed or upgrade history. + +See [architecture, SQL roles and migration](../docs/SILENT_CHICKEN.md). This is an +unpublished opt-in candidate with two separately provisioned trusted stores, not +a new general connector or a claim of stronger legacy state continuity. diff --git a/mcp/package-lock.json b/mcp/package-lock.json index 2bfd9cf6..1600f05d 100644 --- a/mcp/package-lock.json +++ b/mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@once-agent/mcp", - "version": "0.2.1", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@once-agent/mcp", - "version": "0.2.1", + "version": "0.2.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/client": "2.3.1", @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/node": "^26.6.1", + "pg": "8.16.3", "tsx": "^4.20.0", "typescript": "^5.9.0" }, @@ -666,6 +667,103 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -675,6 +773,49 @@ "node": ">=16.20.0" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -696,6 +837,16 @@ "node": ">=8" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/tsx": { "version": "4.23.13", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", @@ -750,6 +901,16 @@ "node": ">= 8" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/mcp/package.json b/mcp/package.json index b5b43261..aefc2e9b 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@once-agent/mcp", - "version": "0.2.1", + "version": "0.2.3", "mcpName": "io.github.stringsofthemind-oss/once", "description": "MCP idempotency and AI agent retry safety for consequential writes and ambiguous timeouts.", "type": "module", @@ -35,9 +35,14 @@ "COMPATIBILITY.md" ], "scripts": { + "test:hardening": "npm run build && node scripts/silent-chicken-evidence.mjs", + "package:hardening": "node scripts/package-silent-chicken.mjs", + "test:summa42-host": "node scripts/summa42-host-proof.mjs", + "test:summa42": "npm run build && node scripts/summa42-evidence.mjs", + "package:summa42": "node scripts/package-summa42.mjs", "build": "tsc -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:registered-action": "npm run build && node --test test/registered-action.test.mjs", + "test:registered-action": "npm run build && node --test test/registered-action.test.mjs test/intent-capability.test.mjs", "test:registered-action-package": "npm run build && node test/registered-action-package.mjs", "start": "node dist/index.js", "dev": "tsx src/index.ts", @@ -81,6 +86,7 @@ }, "devDependencies": { "@types/node": "^26.6.1", + "pg": "8.16.3", "tsx": "^4.20.0", "typescript": "^5.9.0" }, diff --git a/mcp/release-artifact.json b/mcp/release-artifact.json index 380a5519..51fe227e 100644 --- a/mcp/release-artifact.json +++ b/mcp/release-artifact.json @@ -1,7 +1,7 @@ { "name": "@once-agent/mcp", - "version": "0.2.1", - "integrity": "sha512-M4lofyqx/bLlQC1K88ECb7M2ETTL8aoURHZl96yuFZ03DGiufN1mqKb5A2/w6cQozNKNiiV8U5PQX2burWWLRg==", - "sha256": "4afa6a507f788ca51253c27d610120322050be0f737b87371e924c9916b99534", - "files": 19 + "version": "0.2.3", + "integrity": "sha512-fDMv+ONu7P/EArz5upCGtayMGzwxU96lWwL471JXfnJQoXcUWUSTfXPNtwDyCyL5Yk0/LOXMfGTgkkREGgHRSw==", + "sha256": "287ddb88a5d810c417cfb936ef2281fd0e47cd686f3251791c83769c3b3b5339", + "status": "unpublished local qualification candidate; independent npm gate still required" } diff --git a/mcp/scripts/package-silent-chicken.mjs b/mcp/scripts/package-silent-chicken.mjs new file mode 100644 index 00000000..0fc3b679 --- /dev/null +++ b/mcp/scripts/package-silent-chicken.mjs @@ -0,0 +1,44 @@ +// Reuse the baseline packager; only add the admitted host profile and its SQL. +import { spawnSync } from 'node:child_process'; +import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +const destination = path.resolve(process.argv[2] ?? ''); +assert.ok(process.argv[2] && !existsSync(destination), 'Supply an absolute new output directory'); +const baseline = spawnSync(process.execPath, [fileURLToPath(new URL('./package-summa42.mjs', import.meta.url)), destination], { env: process.env, stdio: 'inherit' }); +assert.equal(baseline.status, 0); +const repo = fileURLToPath(new URL('../../', import.meta.url)); +mkdirSync(path.join(destination, 'baseline-docs')); +for (const file of ['README.md', 'EVIDENCE.md', 'GITHUB-RESPONSE.md']) copyFileSync(path.join(destination, file), path.join(destination, 'baseline-docs', file)); +const reference = path.join(repo, 'examples/summa42-host-integration'); +copyFileSync(path.join(reference, 'EVIDENCE-PACKAGE.md'), path.join(destination, 'README.md')); +writeFileSync(path.join(destination, 'ACTUAL-SUMMA42.md'), readFileSync(path.join(reference, 'README.md'), 'utf8').replaceAll('examples/summa42-host-integration/main.go', 'reference/main.go').replaceAll('mcp/scripts/summa42-host-proof.mjs', 'scripts/summa42-host-proof.mjs')); +copyFileSync(path.join(reference, 'compose.yaml'), path.join(destination, 'compose.yaml')); +copyFileSync(path.join(reference, 'ACCEPTANCE.md'), path.join(destination, 'ACCEPTANCE.md')); +copyFileSync(path.join(repo, 'docs/SILENT_CHICKEN.md'), path.join(destination, 'ENGINEERING.md')); +mkdirSync(path.join(destination, 'reference')); copyFileSync(path.join(reference, 'main.go'), path.join(destination, 'reference/main.go')); +for (const file of ['scripts/silent-chicken-evidence.mjs', 'scripts/verify-hardening.mjs', 'scripts/verify-summa42-host.mjs', 'scripts/summa42-host-proof.mjs', 'test/silent-chicken.test.mjs', 'test/intent-capability.test.mjs', 'test/registered-action.test.mjs', 'test/connection-verification.test.mjs']) { + const target = path.join(destination, file); mkdirSync(path.dirname(target), { recursive: true }); copyFileSync(path.join(repo, 'mcp', file), target); +} +cpSync(path.join(repo, 'sdk/typescript/sql'), path.join(destination, 'sql'), { recursive: true }); +const pkgFile = path.join(destination, 'package.json'), pkg = JSON.parse(readFileSync(pkgFile)); +pkg.name = 'once-silent-chicken-evidence'; pkg.scripts['test:baseline'] = pkg.scripts.test; +pkg.scripts.test = 'node scripts/silent-chicken-evidence.mjs'; pkg.scripts.verify = 'node scripts/verify-hardening.mjs'; +pkg.scripts['verify:summa42-host'] = 'node scripts/verify-summa42-host.mjs'; +pkg.scripts['test:regression'] = 'node --test test/registered-action.test.mjs test/connection-verification.test.mjs test/intent-capability.test.mjs'; +pkg.scripts['test:summa42-host'] = 'node scripts/summa42-host-proof.mjs'; pkg.dependencies.pg = '8.16.3'; +writeFileSync(pkgFile, JSON.stringify(pkg, null, 2)); +const provenanceFile = path.join(destination, 'provenance.json'), provenance = JSON.parse(readFileSync(provenanceFile)); +const changed = spawnSync('git', ['diff', '--name-only', 'origin/main'], { cwd: repo, encoding: 'utf8' }); assert.equal(changed.status, 0); +for (const file of changed.stdout.trim().split('\n').filter(Boolean)) { + const target = path.join(destination, 'source', file); mkdirSync(path.dirname(target), { recursive: true }); copyFileSync(path.join(repo, file), target); + provenance.sourceHashes[file] = createHash('sha256').update(readFileSync(path.join(repo, file))).digest('hex'); +} +const patch = spawnSync('git', ['diff', '--binary', 'origin/main'], { cwd: repo, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); assert.equal(patch.status, 0); +writeFileSync(path.join(destination, 'source/complete-source.patch'), patch.stdout); +provenance.summa42Revision = 'efc6794ea79a4be5b2f60e376833a1d03cba053c'; +provenance.reusedAuthorityRevision = 'ae906f64fa6c532d8f2052f4d13d9f13002fa077'; +writeFileSync(provenanceFile, JSON.stringify(provenance, null, 2)); +console.log(`Created independent admitted-host package: ${destination}`); diff --git a/mcp/scripts/package-summa42.mjs b/mcp/scripts/package-summa42.mjs new file mode 100644 index 00000000..4c838497 --- /dev/null +++ b/mcp/scripts/package-summa42.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +const repo = fileURLToPath(new URL('../../', import.meta.url)); +const destination = path.resolve(process.argv[2] ?? ''); +assert.ok(process.argv[2], 'Use npm run package:summa42 -- ABSOLUTE_NEW_OUTPUT_DIRECTORY'); +assert.ok(!existsSync(destination), 'Output directory must not already exist'); +const npmCli = process.env.npm_execpath; +assert.ok(npmCli, 'Run through npm run package:summa42'); +const run = (exe, args, cwd = repo) => { + const result = spawnSync(exe, args, { cwd, encoding: 'utf8', timeout: 180000, maxBuffer: 10 * 1024 * 1024 }); + assert.equal(result.status, 0, `${args.join(' ')}\n${result.stdout}\n${result.stderr}`); + return result.stdout; +}; +for (const directory of ['sdk/typescript', 'mcp']) run(process.execPath, [npmCli, 'run', 'build'], path.join(repo, directory)); +mkdirSync(path.join(destination, 'vendor'), { recursive: true }); +const packages = {}; +for (const directory of ['sdk/typescript', 'mcp']) { + const [packed] = JSON.parse(run(process.execPath, [npmCli, 'pack', '--ignore-scripts', '--json', '--pack-destination', path.join(destination, 'vendor')], path.join(repo, directory))); + packages[packed.name] = { version: packed.version, filename: packed.filename, integrity: packed.integrity, sha256: createHash('sha256').update(readFileSync(path.join(destination, 'vendor', packed.filename))).digest('hex') }; +} +const copied = [ + 'mcp/test/summa42.test.mjs', 'mcp/test/fixtures/order-host.mjs', 'mcp/test/fixtures/order-provider.mjs', + 'mcp/scripts/summa42-evidence.mjs', 'mcp/scripts/verify-summa42.mjs', +]; +for (const relative of copied) { + const target = path.join(destination, relative.slice(4)); mkdirSync(path.dirname(target), { recursive: true }); + copyFileSync(path.join(repo, relative), target); +} +for (const filename of ['README.md', 'EVIDENCE.md', 'GITHUB-RESPONSE.md']) copyFileSync(path.join(repo, 'examples/summa42-hostile-retry', filename), path.join(destination, filename)); +copyFileSync(path.join(repo, 'LICENSE'), path.join(destination, 'LICENSE')); +const auditFiles = ['AGENTS.md', 'docs/published-versions.json', 'sdk/typescript/src/local.ts', 'sdk/typescript/src/tool-call.ts', + 'sdk/typescript/src/connect/mcp-execution-boundary.ts', 'sdk/typescript/src/connect/identity.ts', + 'mcp/src/registered-action.ts', 'mcp/src/existing-ledger.ts', 'mcp/test/registered-action.test.mjs', + 'sdk/typescript/test/tool-call.test.mjs', 'examples/first10/prove.mjs']; +const sourceHashes = {}; +for (const relative of [...auditFiles, ...copied]) { + sourceHashes[relative] = createHash('sha256').update(readFileSync(path.join(repo, relative))).digest('hex'); + const target = path.join(destination, 'source', relative); mkdirSync(path.dirname(target), { recursive: true }); + copyFileSync(path.join(repo, relative), target); +} +const provenance = { schema: 'once-summa42-provenance-v1', generatedAt: new Date().toISOString(), + repository: 'https://github.com/stringsofthemind-oss/once', baseRevision: run('git', ['merge-base', 'HEAD', 'origin/main']).trim(), + checkoutRevision: run('git', ['rev-parse', 'HEAD']).trim(), branch: run('git', ['branch', '--show-current']).trim(), + status: run('git', ['status', '--short']), packages, sourceHashes, artifactQualification: 'local source builds; not publication evidence', + criterionSource: 'https://github.com/SofiaFlux/summa42/issues/35' }; +writeFileSync(path.join(destination, 'provenance.json'), JSON.stringify(provenance, null, 2)); +writeFileSync(path.join(destination, 'source', 'tracked-changes.patch'), run('git', ['diff', '--binary', 'HEAD'])); +writeFileSync(path.join(destination, 'package.json'), JSON.stringify({ name: 'once-summa42-evidence', version: '1.0.0', private: true, type: 'module', + engines: { node: '>=24.15.0' }, scripts: { test: 'node scripts/summa42-evidence.mjs', verify: 'node scripts/verify-summa42.mjs' }, + dependencies: { '@once-agent/sdk': `file:vendor/${packages['@once-agent/sdk'].filename}`, '@once-agent/mcp': `file:vendor/${packages['@once-agent/mcp'].filename}`, '@modelcontextprotocol/client': '2.3.1' }, + overrides: { '@once-agent/sdk': '$@once-agent/sdk' }, +}, null, 2)); +console.log(`Created standalone package: ${destination}\nNext: npm install --package-lock-only --ignore-scripts --no-audit --no-fund, then npm ci --ignore-scripts and npm test in that directory.`); diff --git a/mcp/scripts/silent-chicken-evidence.mjs b/mcp/scripts/silent-chicken-evidence.mjs new file mode 100644 index 00000000..0d5c8b6e --- /dev/null +++ b/mcp/scripts/silent-chicken-evidence.mjs @@ -0,0 +1,27 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +const parent = path.resolve(process.argv[2] ?? 'silent-chicken-runs'); mkdirSync(parent, { recursive: true }); +const directory = mkdtempSync(path.join(parent, 'run-')); +const source = fileURLToPath(new URL('../test/silent-chicken.test.mjs', import.meta.url)); +const repositorySql = fileURLToPath(new URL('../../sdk/typescript/sql/', import.meta.url)); +const sqlRoot = process.env.ONCE_SQL_ROOT ?? (existsSync(repositorySql) ? repositorySql : fileURLToPath(new URL('../sql/', import.meta.url))); +const command = [process.execPath, '--test', '--test-reporter=tap', source]; +const execution = { startedAt: new Date().toISOString(), command, runtime: process.version, platform: process.platform, + suiteSha256: createHash('sha256').update(readFileSync(source)).digest('hex'), packages: {} }; +for (const specifier of ['@once-agent/sdk', '@once-agent/mcp/registered-action']) { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.resolve(specifier)))); execution.packages[pkg.name] = pkg.version; +} +let transcript = ''; +const child = spawn(command[0], command.slice(1), { env: { ...process.env, ONCE_HARDENING_RESULTS: directory, ONCE_SQL_ROOT: sqlRoot }, stdio: ['ignore', 'pipe', 'pipe'] }); +for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { transcript += chunk; process.stdout.write(chunk); }); +const timer = setTimeout(() => child.kill(), 240000); +child.on('error', error => { execution.spawnError = error.message; }); +child.on('close', (code, signal) => { + clearTimeout(timer); Object.assign(execution, { finishedAt: new Date().toISOString(), exitCode: code, signal }); + writeFileSync(path.join(directory, 'test-output.tap'), transcript); + writeFileSync(path.join(directory, 'execution.json'), JSON.stringify(execution, null, 2)); + console.log(`Saved hardening evidence: ${directory}`); process.exitCode = code ?? 1; +}); diff --git a/mcp/scripts/smoke.mjs b/mcp/scripts/smoke.mjs index 48a52fab..6103df23 100644 --- a/mcp/scripts/smoke.mjs +++ b/mcp/scripts/smoke.mjs @@ -26,7 +26,7 @@ try { await client.connect(transport); const serverVersion = client.getServerVersion(); - if (serverVersion?.name !== "once-agent" || serverVersion.version !== "0.2.1") { + if (serverVersion?.name !== "once-agent" || serverVersion.version !== "0.2.3") { throw new Error( `Unexpected MCP server identity/version: ${JSON.stringify(serverVersion)}` ); diff --git a/mcp/scripts/summa42-evidence.mjs b/mcp/scripts/summa42-evidence.mjs new file mode 100644 index 00000000..c9a1b782 --- /dev/null +++ b/mcp/scripts/summa42-evidence.mjs @@ -0,0 +1,30 @@ +import { spawn } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +const parent = path.resolve(process.argv[2] ?? 'summa42-runs'); +mkdirSync(parent, { recursive: true }); +const directory = mkdtempSync(path.join(parent, 'run-')); +const source = fileURLToPath(new URL('../test/summa42.test.mjs', import.meta.url)); +const command = [process.execPath, '--test', '--test-reporter=tap', source]; +const execution = { startedAt: new Date().toISOString(), command, runtime: process.version, platform: process.platform, + suiteSha256: createHash('sha256').update(readFileSync(source)).digest('hex'), packages: {} }; +for (const specifier of ['@once-agent/sdk', '@once-agent/mcp/registered-action']) { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.resolve(specifier)))); + execution.packages[pkg.name] = pkg.version; +} +let transcript = ''; +const child = spawn(command[0], command.slice(1), { env: { ...process.env, ONCE_SUMMA42_RESULTS: directory }, stdio: ['ignore', 'pipe', 'pipe'] }); +for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { transcript += chunk; process.stdout.write(chunk); }); +const timer = setTimeout(() => child.kill(), 240000); +child.on('error', error => { execution.spawnError = error.message; }); +child.on('close', (code, signal) => { + clearTimeout(timer); + Object.assign(execution, { finishedAt: new Date().toISOString(), exitCode: code, signal }); + writeFileSync(path.join(directory, 'test-output.tap'), transcript); + writeFileSync(path.join(directory, 'execution.json'), JSON.stringify(execution, null, 2)); + console.log(`Saved evidence: ${directory}`); + process.exitCode = code ?? 1; +}); diff --git a/mcp/scripts/summa42-host-proof.mjs b/mcp/scripts/summa42-host-proof.mjs new file mode 100644 index 00000000..6138194e --- /dev/null +++ b/mcp/scripts/summa42-host-proof.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { randomUUID, createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; +import pg from 'pg'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { EMPTY_HOST_AUTHORITY_DIGEST, admitPostgresIntent, createPostgresContinuityWitness } from '@once-agent/sdk'; +import { startOrderProvider } from '../test/fixtures/order-provider.mjs'; + +const binary = process.env.ONCE_SUMMA42_BINARY; +assert.ok(binary && process.env.ONCE_TEST_POSTGRES && process.env.ONCE_TEST_WITNESS, 'Supply the compiled actual Summa42 fixture and two disposable PostgreSQL clusters'); +const parent = path.resolve(process.argv[2] ?? 'summa42-host-runs'); mkdirSync(parent, { recursive: true }); +const root = mkdtempSync(path.join(parent, 'run-')), home = path.join(root, 'actual-summa42'); mkdirSync(home); +const events = [], hosts = []; +const log = (event, data = {}) => events.push({ sequence: events.length + 1, at: new Date().toISOString(), event, ...JSON.parse(JSON.stringify(data)) }); +const bridgeToken = randomUUID(); +let bridgeUrl, source, binding, holdUnknown, releaseUnknown; +const firstUnknown = new Promise(resolve => { releaseUnknown = resolve; }); +function startGo(stage) { + const child = spawn(binary, [], { env: { ...process.env, ONCE_FIXTURE_ONLY: 'disposable-summa42', ONCE_SUMMA42_FIXTURE: home, ONCE_SUMMA42_STAGE: stage, ONCE_BRIDGE_URL: bridgeUrl ?? '', ONCE_BRIDGE_TOKEN: bridgeToken }, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; child.stdout.on('data', x => { stdout += x; }); child.stderr.on('data', x => { stderr += x; }); + const done = new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill(); reject(Error(`Summa42 ${stage} timed out`)); }, 20000); + child.on('error', reject); + child.on('close', (code, signal) => { + clearTimeout(timer); if (signal) { resolve({ code, signal }); return; } + if (code !== 0) { reject(Error(`Summa42 ${stage} failed: ${stderr}`)); return; } + const result = JSON.parse(stdout.trim().split('\n').at(-1)); log('actual-summa42-stage', { stage, result }); resolve(result); + }); + }); + return { child, done }; +} +const sqliteRows = () => { + const db = new DatabaseSync(path.join(home, 'summa42.sqlite'), { readOnly: true }); + try { return db.prepare('SELECT operation_id,attempt_id,effect_slot_id,state,provider_reference,intent_fingerprint FROM external_operations').all().map(x => ({ ...x })); } finally { db.close(); } +}; +const attemptCount = () => { + const db = new DatabaseSync(path.join(home, 'summa42.sqlite'), { readOnly: true }); + try { return db.prepare('SELECT count(*) AS n FROM attempts').get().n; } finally { db.close(); } +}; +const admins = [process.env.ONCE_TEST_POSTGRES, process.env.ONCE_TEST_WITNESS].map(connectionString => new pg.Pool({ connectionString })); +const names = [0, 1].map(() => `once_summa_${randomUUID().replaceAll('-', '')}`); +for (let i = 0; i < 2; i++) await admins[i].query(`CREATE DATABASE ${names[i]}`); +const urls = [process.env.ONCE_TEST_POSTGRES, process.env.ONCE_TEST_WITNESS].map((base, i) => { const u = new URL(base); u.pathname = `/${names[i]}`; return u.href; }); +const [pool, witnessPool] = urls.map(connectionString => new pg.Pool({ connectionString })); +for (const p of [...admins, pool, witnessPool]) p.on('error', () => {}); +const repositorySql = fileURLToPath(new URL('../../sdk/typescript/sql/', import.meta.url)); +const sqlRoot = process.env.ONCE_SQL_ROOT ?? (existsSync(repositorySql) ? repositorySql : fileURLToPath(new URL('../sql/', import.meta.url))); +await pool.query(readFileSync(path.join(sqlRoot, 'execution-authority-v1.sql'), 'utf8')); +await pool.query(readFileSync(path.join(sqlRoot, 'execution-host-intents-v2.sql'), 'utf8')); +await witnessPool.query(readFileSync(path.join(sqlRoot, 'continuity-witness-v1.sql'), 'utf8')); +await witnessPool.query(readFileSync(path.join(sqlRoot, 'continuity-witness-v2.sql'), 'utf8')); +const authorityId = randomUUID(), witnessId = randomUUID(); +await pool.query("INSERT INTO once_execution.authorities VALUES($1,2,'g1',1,0,$2)", [authorityId, EMPTY_HOST_AUTHORITY_DIGEST]); +await witnessPool.query('INSERT INTO once_continuity.metadata VALUES(true,$1,2)', [witnessId]); +await witnessPool.query("INSERT INTO once_continuity.checkpoints VALUES($1,'g1',1,0,$2)", [authorityId, EMPTY_HOST_AUTHORITY_DIGEST]); +const witness = createPostgresContinuityWitness({ pool: witnessPool, expectedWitnessId: witnessId, expectedSchemaVersion: 2 }); +const provider = await startOrderProvider(path.join(root, 'provider.jsonl'), { onCommit: receipt => log('provider-commit-fsynced', { receipt }), onAckDropped: receipt => log('provider-ack-dropped', { operationId: receipt.operationId }) }); +async function host(lookupOnly) { + const config = { providerUrl: provider.url, token: provider.token, accountId: 'account-A', resourceId: 'orders-local', trustedIntentKey: binding.intentKey, leaseMs: 2000, lookupOnly, + shared: { executionUrl: urls[0], witnessUrl: urls[1], authorityId, witnessId, generation: 'g1', epoch: '1', scope: binding.scope } }; + const transport = new StdioClientTransport({ command: process.execPath, args: [fileURLToPath(new URL('../test/fixtures/order-host.mjs', import.meta.url))], env: { ...process.env, ONCE_TEST_HOST: JSON.stringify(config) }, stderr: 'pipe' }); + const client = new Client({ name: `actual-summa42-${lookupOnly ? 'read-only-recovery' : 'dispatch'}`, version: '1' }); + let diagnostics = ''; transport.stderr.on('data', x => { diagnostics += x; }); + try { await client.connect(transport); } catch { throw Error(`MCP host startup failed: ${diagnostics}`); } + hosts.push(client); return client; +} +let dispatchHost, lookupHost; +const server = createServer(async (req, res) => { + if (req.headers.authorization !== `Bearer ${bridgeToken}`) { res.writeHead(401).end(); return; } + if (req.method !== 'POST' || !['/dispatch', '/lookup'].includes(req.url)) { res.writeHead(404).end(); return; } + try { + let body = ''; for await (const chunk of req) body += chunk; + const data = JSON.parse(body); + const valid = data.OperationID === source.operationId && data.TaskID === source.taskId && data.EffectSlotID === source.effectSlotId && + data.IntentFingerprint === source.intentFingerprint && data.IntentRevision === 1 && + Buffer.from(data.CanonicalIntent, 'base64').toString() === source.canonical; + if (!valid) { log('untrusted-context-denied'); res.writeHead(409).end(JSON.stringify({ status: 'CONFLICT' })); return; } + log('actual-provider-interface', { mode: req.url, operationId: data.OperationID, effectSlotId: data.EffectSlotID }); + const response = await (req.url === '/dispatch' ? dispatchHost : lookupHost).callTool({ name: 'once_create_order', arguments: { operationId: binding.operationId, args: { sku: 'SKU-1', quantity: 2, destinationId: 'address-1' } } }); + const outcome = JSON.parse(response.content[0].text); log('bridge-outcome', { mode: req.url, outcome }); + if (holdUnknown && req.url === '/dispatch' && outcome.code === 'UNKNOWN') { releaseUnknown(); return; } // no response: parent kills the actual Go worker here + res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(outcome)); + } catch { res.writeHead(503).end(JSON.stringify({ status: 'UNKNOWN' })); } +}); +let success = false; +try { + source = await startGo('init').done; + const db = new DatabaseSync(path.join(home, 'summa42.sqlite'), { readOnly: true }); + const slot = db.prepare('SELECT * FROM effect_slots WHERE effect_slot_id=?').get(source.effectSlotId); + const op = sqliteRows()[0]; db.close(); + source.intentFingerprint = op.intent_fingerprint; + source.canonical = slot.canonical_intent_json; + assert.equal(typeof source.canonical, 'string'); + const effect = { tool: 'once_create_order', args: { contract: 'once-registered-order-v1', action: 'once_create_order', authority: { provider: 'disposable-orders', accountId: 'account-A', environment: 'local-test' }, resourceId: 'orders-local', providerArgs: { sku: 'SKU-1', quantity: 2, destinationId: 'address-1' } } }; + binding = await admitPostgresIntent({ pool, witness, authorityId, expectedGeneration: 'g1', expectedEpoch: '1' }, { scope: 'trusted-summa42-collective', intentKey: `${source.taskId}/${source.effectSlotId}`, effect }); + log('operator-admitted-actual-summa42-slot', { source, binding }); + dispatchHost = await host(false); lookupHost = await host(true); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); bridgeUrl = `http://127.0.0.1:${server.address().port}`; + provider.modes.set(binding.operationId, 'lost-ack'); provider.observations.set(binding.operationId, { status: 'UNKNOWN' }); + holdUnknown = true; + const worker = startGo('dispatch'); + await Promise.race([firstUnknown, worker.done.then(() => { throw Error('Go worker exited before acknowledgement-loss kill barrier'); })]); + assert.equal(provider.records().length, 1); + assert.equal(sqliteRows()[0].state, 'DISPATCHED'); + worker.child.kill('SIGKILL'); const killed = await worker.done; assert.equal(killed.signal, 'SIGKILL'); + log('actual-summa42-worker-killed', { pid: worker.child.pid, ledger: sqliteRows() }); holdUnknown = false; + const blocked = []; + for (let i = 0; i < 3; i++) { + const result = await startGo('recover').done; blocked.push(result); + assert.equal(result.state, 'OUTCOME_UNKNOWN'); assert.equal(result.reservation, 'UNRESOLVED'); + assert.equal(result.hostGate, 'BLOCKED_UNRESOLVED'); assert.equal(attemptCount(), 1); + assert.equal(result.operationId, source.operationId); assert.equal(result.effectSlotId, source.effectSlotId); + assert.equal(provider.records().length, 1); assert.equal(provider.counters.posts, 1); + } + provider.observations.delete(binding.operationId); + const recovered = await startGo('recover').done; + assert.equal(recovered.state, 'CONFIRMED_EFFECT'); assert.equal(recovered.providerReference, provider.records()[0].providerReference); + const lookups = provider.counters.lookups; + const replay = await startGo('recover').done; + assert.equal(replay.state, 'CONFIRMED_EFFECT'); assert.equal(replay.providerReference, recovered.providerReference); + assert.equal(provider.counters.lookups, lookups); assert.equal(provider.counters.posts, 1); + assert.equal(new Set(blocked.map(x => x.pid)).size, 3); + for (const result of blocked) assert.equal(result.attemptId, source.attemptId); + assert.notEqual(recovered.attemptId, source.attemptId); assert.equal(attemptCount(), 3); + success = true; +} finally { + for (const client of hosts) await client.close(); + server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); + await provider.close(); + writeFileSync(path.join(root, 'events.jsonl'), events.map(x => JSON.stringify(x)).join('\n') + '\n'); + writeFileSync(path.join(root, 'results.json'), JSON.stringify({ success, summa42Revision: process.env.ONCE_SUMMA42_REVISION ?? 'unrecorded', + binarySha256: createHash('sha256').update(readFileSync(binary)).digest('hex'), actualServices: ['operations.Service', 'execution.Service', 'resources.Service', 'approvals.Service', 'sqlite.Store'], + scope: 'actual pinned Summa42 services with reference host gate before replacement Attempt creation; no Box/LLM/global-scheduler conformance claim', + gateAppliedBeforeReplacementAttempt: true, finalAttemptCount: attemptCount(), + source, binding, providerCounters: provider.counters, providerEffects: provider.records().length, finalSumma42Ledger: sqliteRows() }, null, 2)); + for (const p of [pool, witnessPool]) await p.end(); + for (let i = 0; i < 2; i++) { await admins[i].query(`DROP DATABASE ${names[i]}`); await admins[i].end(); } + console.log(`Actual Summa42 service evidence: ${root}`); +} diff --git a/mcp/scripts/verify-hardening.mjs b/mcp/scripts/verify-hardening.mjs new file mode 100644 index 00000000..144fa5ef --- /dev/null +++ b/mcp/scripts/verify-hardening.mjs @@ -0,0 +1,63 @@ +// Independent evidence invariants: intentionally no Once, MCP or SQL imports. +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +const [mode, directory] = process.argv.slice(2); +assert.ok(['baseline', 'protected'].includes(mode) && directory, 'Use: node verify-hardening.mjs baseline|protected RUN_DIRECTORY'); +const root = path.resolve(directory); +const lines = file => readFileSync(path.join(root, file), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); +const json = file => JSON.parse(readFileSync(path.join(root, file), 'utf8')); +if (mode === 'baseline') { + const counts = { T06: lines('T06/provider.jsonl').length, T07: lines('T07/provider.jsonl').length }; + writeFileSync(path.join(root, 'protection-invariants.json'), JSON.stringify({ phase: 'RED', requiredMaximum: 1, T06: { effects: counts.T06, protected: counts.T06 <= 1 }, T07: { effects: counts.T07, protected: counts.T07 <= 1 } }, null, 2)); + console.log(JSON.stringify({ phase: 'RED', requiredMaximumEffectsPerIntent: 1, observed: counts })); + assert.equal(counts.T06, 1, 'T06 protection invariant RED: planner identity substitution caused another effect'); + assert.equal(counts.T07, 1, 'T07 protection invariant RED: replacement ledger caused another effect'); +} else { + const results = json('results.json'); assert.equal(results.success, true); assert.equal(json('execution.json').exitCode, 0); + const events = lines('events.jsonl'), journal = lines('provider.jsonl'); + assert.equal(json('provider-counters.json').posts, journal.length); + const counts = new Map(); for (const receipt of journal) counts.set(receipt.operationId, (counts.get(receipt.operationId) ?? 0) + 1); + for (const count of counts.values()) assert.equal(count, 1, 'no admitted operation may produce a second effect'); + const ids = {}; + for (const scenario of ['H01', 'H02', 'H03', 'H04']) { + const start = events.findIndex(x => x.event === 'scenario-start' && x.id === scenario); + const finish = events.findIndex((x, i) => i > start && x.event === 'scenario-end' && x.id === scenario); + const trace = events.slice(start, finish + 1); + const binding = trace.find(x => x.event === 'host-intent-admitted').binding; + ids[scenario] = binding.operationId; + assert.equal(counts.get(binding.operationId) ?? 0, scenario === 'H04' ? 0 : 1); + if (scenario === 'H01') { + assert.ok(trace.some(x => x.result?.code === 'IDENTITY_CONFLICT')); + const newIntent = trace.find(x => x.event === 'separately-authorized-new-intent').binding; + assert.notEqual(newIntent.operationId, binding.operationId); assert.notEqual(newIntent.intentKey, binding.intentKey); + assert.equal(counts.get(newIntent.operationId), 1); + } + if (scenario === 'H02') assert.ok(trace.some(x => x.result?.code === 'CONTINUITY_LOST')); + if (scenario === 'H03') { + const commit = trace.findIndex(x => x.event === 'provider-commit-fsynced'); + const dropped = trace.findIndex(x => x.event === 'provider-ack-dropped'); + const killed = trace.findIndex(x => x.event === 'worker-killed-after-provider-commit'); + assert.ok(commit >= 0 && dropped > commit && killed > dropped); + assert.ok(trace.filter(x => x.result?.code === 'UNKNOWN').length >= 3); + assert.equal(trace.filter(x => x.result?.status === 'CONFIRMED').length, 2); + assert.ok(new Set(trace.filter(x => x.event === 'mcp-request').map(x => x.sessionId)).size >= 5); + } + if (scenario === 'H04') assert.ok(trace.some(x => x.result?.code === 'UNKNOWN')); + } + const report = { success: true, phase: 'GREEN', checkerImports: 'Node built-ins only', namedScenarios: results.passed, + canonicalT06Effects: counts.get(ids.H01), canonicalT07Effects: counts.get(ids.H02), + commitCrashEffects: counts.get(ids.H03), preEffectCrashEffects: counts.get(ids.H04) ?? 0, + providerEffects: journal.length, maxEffectsPerAdmittedOperation: Math.max(...counts.values()) }; + const snapshot = json('authority-snapshot.json'); + const preEffect = snapshot.operations.find(x => x.id === ids.H04); + assert.equal(preEffect.state, 'UNKNOWN'); assert.equal(preEffect.result_json, null); + for (const authority of snapshot.authorities) { + const checkpoint = snapshot.checkpoints.find(x => x.authority_id === authority.authority_id); + assert.equal(checkpoint.generation, authority.generation); + assert.equal(String(checkpoint.epoch), String(authority.epoch)); + assert.equal(String(checkpoint.revision), String(authority.revision)); + assert.equal(checkpoint.state_digest, authority.state_digest); + } + writeFileSync(path.join(root, 'independent-check.json'), JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2)); +} diff --git a/mcp/scripts/verify-summa42-host.mjs b/mcp/scripts/verify-summa42-host.mjs new file mode 100644 index 00000000..47fee5f9 --- /dev/null +++ b/mcp/scripts/verify-summa42-host.mjs @@ -0,0 +1,35 @@ +// Independent durable-state check: no Once, MCP or PostgreSQL client imports. +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import path from 'node:path'; +const root = path.resolve(process.argv[2] ?? ''); assert.ok(process.argv[2], 'Supply an actual Summa42 service run directory'); +const json = file => JSON.parse(readFileSync(path.join(root, file), 'utf8')); +const lines = file => readFileSync(path.join(root, file), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); +const result = json('results.json'), events = lines('events.jsonl'), provider = lines('provider.jsonl'); +assert.equal(result.success, true); assert.equal(result.gateAppliedBeforeReplacementAttempt, true); +assert.equal(result.summa42Revision, 'efc6794ea79a4be5b2f60e376833a1d03cba053c'); +assert.equal(provider.length, 1); assert.equal(result.providerCounters.posts, 1); +assert.equal(provider[0].operationId, result.binding.operationId); +const blocked = events.filter(x => x.event === 'actual-summa42-stage' && x.result.hostGate === 'BLOCKED_UNRESOLVED'); +assert.equal(blocked.length, 3); assert.equal(new Set(blocked.map(x => x.result.pid)).size, 3); +for (const { result: state } of blocked) { + assert.equal(state.state, 'OUTCOME_UNKNOWN'); assert.equal(state.reservation, 'UNRESOLVED'); + assert.equal(state.attemptId, result.source.attemptId); assert.equal(state.operationId, result.source.operationId); +} +const killed = events.find(x => x.event === 'actual-summa42-worker-killed'); +assert.equal(killed.ledger[0].state, 'DISPATCHED'); +const confirmed = events.filter(x => x.event === 'actual-summa42-stage' && x.result.state === 'CONFIRMED_EFFECT'); +assert.equal(confirmed.length, 2); +const db = new DatabaseSync(path.join(root, 'actual-summa42/summa42.sqlite'), { readOnly: true }); +try { + const attempts = db.prepare('SELECT attempt_id FROM attempts').all().map(x => x.attempt_id); + assert.deepEqual(new Set(attempts), new Set([result.source.attemptId, ...confirmed.map(x => x.result.attemptId)])); + const op = db.prepare('SELECT * FROM external_operations').all(); assert.equal(op.length, 1); + assert.equal(op[0].state, 'CONFIRMED_EFFECT'); assert.equal(op[0].provider_reference, provider[0].providerReference); + assert.equal(op[0].effect_slot_id, result.source.effectSlotId); + assert.equal(db.prepare('SELECT state FROM resource_reservations').get().state, 'SETTLED'); +} finally { db.close(); } +const checked = { success: true, checkerImports: 'Node built-ins only', actualServiceProcesses: new Set(events.filter(x => x.event === 'actual-summa42-stage').map(x => x.result.pid)).size + 1, + unknownWorkersBlockedBeforeNewAttempt: 3, providerPosts: 1, providerEffects: 1, finalAttempts: 3, globalBoxPlannerConformance: 'unverified' }; +writeFileSync(path.join(root, 'independent-check.json'), JSON.stringify(checked, null, 2)); console.log(JSON.stringify(checked, null, 2)); diff --git a/mcp/scripts/verify-summa42.mjs b/mcp/scripts/verify-summa42.mjs new file mode 100644 index 00000000..1218a97a --- /dev/null +++ b/mcp/scripts/verify-summa42.mjs @@ -0,0 +1,48 @@ +// Evidence checker intentionally has no Once/MCP imports. +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +const root = path.resolve(process.argv[2] ?? ''); +assert.ok(process.argv[2], 'Supply the run directory printed by the evidence runner'); +const json = relative => JSON.parse(readFileSync(path.join(root, relative), 'utf8')); +const lines = relative => readFileSync(path.join(root, relative), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); +const results = json('results.json'); +assert.equal(json('execution.json').exitCode, 0, 'test process must have exited successfully'); +assert.equal(results.success, true); assert.equal(results.tests.length, 8); assert.equal(results.failed, 0); +assert.equal(results.eventsSha256, createHash('sha256').update(readFileSync(path.join(root, 'events.jsonl'))).digest('hex')); +const events = lines('events.jsonl'); +assert.deepEqual(events.map(x => x.sequence), events.map((_, i) => i + 1)); +const counts = { T01: 2, T02: 1, T03: 1, T04: 1, T05: 1, T06: 2, T07: 2, T08: 1 }; +for (const [test, expected] of Object.entries(counts)) { + assert.equal(lines(`${test}/provider.jsonl`).length, expected, `${test} provider journal`); + assert.equal(json(`${test}/provider-counters.json`).posts, expected, `${test} raw write requests`); + assert.equal(events.filter(x => x.test === test && x.event === 'provider-commit-fsynced').length, expected); +} +const canonical = events.filter(x => x.test === 'T02'); +const index = name => canonical.findIndex(x => x.event === name); +for (const event of ['provider-commit-fsynced', 'provider-acknowledgement-dropped', 'worker-observed-ack-loss-before-once-return', 'worker-killed-call-closed', 'abandoned-claim', 'final-ledger']) assert.ok(index(event) >= 0, `missing event ${event}`); +assert.ok(index('provider-commit-fsynced') < index('provider-acknowledgement-dropped')); +assert.ok(index('provider-acknowledgement-dropped') < index('worker-observed-ack-loss-before-once-return')); +assert.ok(index('worker-observed-ack-loss-before-once-return') < index('worker-killed-call-closed')); +assert.ok(index('worker-killed-call-closed') < index('abandoned-claim')); +const requests = canonical.filter(x => x.event === 'mcp-request'); +assert.equal(new Set(requests.map(x => x.operationId)).size, 1); +assert.ok(new Set(requests.map(x => x.sessionId)).size >= 4); +assert.ok(new Set(requests.map(x => x.attemptId)).size >= 4); +assert.notEqual(requests[0].requestId, requests[1].requestId); +const outcomes = canonical.filter(x => x.event === 'mcp-outcome').map(x => x.outcome); +assert.equal(outcomes[0].code, 'IN_FLIGHT'); +assert.ok(outcomes.filter(x => x.code === 'UNKNOWN').length >= 4); +for (const x of outcomes.filter(x => x.code)) assert.equal(x.retryAllowed, false); +const receipt = lines('T02/provider.jsonl')[0]; +const confirmed = outcomes.filter(x => x.status === 'CONFIRMED'); +assert.equal(confirmed.length, 2); +for (const x of confirmed) assert.deepEqual(x.result, receipt); +const row = canonical.find(x => x.event === 'final-ledger').rows[0]; +assert.equal(row.state, 'CONFIRMED'); assert.deepEqual(JSON.parse(row.result_json).value, receipt); +const checked = { success: true, checker: 'no Once or MCP imports', providerEffectCounts: counts, canonicalOperationId: receipt.operationId, + canonicalSessions: new Set(requests.map(x => x.sessionId)).size, canonicalProviderEffects: 1, + limitsObserved: ['new operation ID can create second effect', 'valid empty replacement ledger across restart can create second effect'] }; +writeFileSync(path.join(root, 'independent-check.json'), JSON.stringify(checked, null, 2)); +console.log(JSON.stringify(checked, null, 2)); diff --git a/mcp/server.json b/mcp/server.json index 08e5b80f..9cb0a863 100644 --- a/mcp/server.json +++ b/mcp/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/stringsofthemind-oss/once", "source": "github" }, - "version": "0.2.1", + "version": "0.2.3", "packages": [ { "registryType": "npm", "identifier": "@once-agent/mcp", - "version": "0.2.1", + "version": "0.2.3", "transport": { "type": "stdio" }, diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 2b24ed5b..e4350d69 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -10,7 +10,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio"; import * as z from "zod/v4"; const SERVER_NAME = "once-agent"; -const SERVER_VERSION = "0.2.1"; +const SERVER_VERSION = "0.2.3"; const MAX_OUTPUT_CHARS = 250_000; const DEFAULT_TIMEOUT_MS = 120_000; diff --git a/mcp/src/registered-action.ts b/mcp/src/registered-action.ts index 323f4b5e..fe956609 100644 --- a/mcp/src/registered-action.ts +++ b/mcp/src/registered-action.ts @@ -3,6 +3,7 @@ import { isAbsolute } from "node:path"; import { McpServer } from "@modelcontextprotocol/server"; import * as z from "zod/v4"; import { canonicalizeConnectPayload, LocalProtectionError, protectToolCall } from "@once-agent/sdk"; +import * as sdk from "@once-agent/sdk"; import { withLocalProtectionSession } from "@once-agent/sdk/connect"; const identifier = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/); @@ -28,6 +29,16 @@ const confirmationSchema = z.strictObject({ export type OrderAuthority = z.infer; export type PreparedOrderEffect = z.infer; export type OrderReceipt = z.infer; +/** Structural host capability keeps legacy SDK installations loadable. New + * intent mode is admitted only when the SDK advertises the matching helper. + */ +export interface RegisteredIntentAuthority { + readonly contract: "once-host-intent-v2"; + open(): Promise; + resolveIntent(scope: string, key: string): Promise<{ + scope: string; intentKey: string; operationId: string; effectFingerprint: string; + }>; +} /** Trusted host capability. This is never supplied through tools/call. * create must perform one unary write with no hidden retries. lookup must only @@ -45,7 +56,18 @@ export interface RegisteredOrderOptions { authority: OrderAuthority; resourceId: string; /** Existing, host-provisioned persistent ledger. Never initialized here. */ - statePath: string; + statePath?: string; + /** Host-owned lease; expiry permits reconciliation only, never redispatch. */ + leaseMs?: number; + /** Opt-in trusted host intent + shared execution authority. Mutually exclusive + * with statePath. Keys come from authenticated host context, never tool args. + * The runtime SQL principal may only SELECT the separately admitted intents. + */ + intentAuthority?: { + authority: RegisteredIntentAuthority; + scope: string; + resolveKey(context: unknown): Promise; + }; provider: RegisteredOrderProvider; /** Host admission/receipt-access check; required even for confirmed replay. */ authorize(context: unknown): Promise; @@ -72,7 +94,13 @@ export async function registerProtectedOrderAction(server: McpServer, options: R const name = identifier.parse(options.name ?? "once_create_order"); const authority = freeze(authoritySchema.parse(options.authority)); const resourceId = identifier.parse(options.resourceId); - if (!isAbsolute(options.statePath) || !options.provider || + const admitted = options.intentAuthority; + const fingerprintEffect = (sdk as unknown as { hostIntentEffectFingerprint?: (effect: { tool: string; args: PreparedOrderEffect }) => string }).hostIntentEffectFingerprint; + const intentContract = (sdk as unknown as { HOST_INTENT_AUTHORITY_CONTRACT?: string }).HOST_INTENT_AUTHORITY_CONTRACT; + if (admitted && (typeof fingerprintEffect !== "function" || intentContract !== "once-host-intent-v2" || admitted.authority?.contract !== intentContract)) throw new LocalProtectionError("UNSUPPORTED_BOUNDARY", "Intent authority requires the qualified state-bound v2 SDK capability; legacy mode is not a fallback."); + if ((admitted ? options.statePath !== undefined || !admitted.scope?.trim() || + typeof admitted.resolveKey !== "function" || typeof admitted.authority?.resolveIntent !== "function" || + typeof admitted.authority?.open !== "function" : typeof options.statePath !== "string" || !isAbsolute(options.statePath)) || !options.provider || typeof options.authorize !== "function" || ["assertAuthority", "create", "lookup"].some(key => typeof options.provider[key as keyof RegisteredOrderProvider] !== "function")) { throw new LocalProtectionError("UNSUPPORTED_BOUNDARY", "A fixed host capability, admission check and absolute persistent ledger are required."); @@ -85,15 +113,20 @@ export async function registerProtectedOrderAction(server: McpServer, options: R const lookup = provider.lookup.bind(provider); const authorize = options.authorize; const statePath = options.statePath; + const leaseMs = options.leaseMs; + const executionAuthority = admitted ? Object.freeze({ open: admitted.authority.open.bind(admitted.authority) }) : undefined; + const scope = admitted?.scope; + const resolveKey = admitted?.resolveKey.bind(admitted); + const resolveIntent = admitted?.authority.resolveIntent.bind(admitted.authority); const checkAuthority = async () => { const observed = authoritySchema.safeParse(await assertAuthority()); if (!observed.success || !same(observed.data, authority)) { throw new LocalProtectionError("UNSUPPORTED_BOUNDARY", "Authenticated provider authority no longer matches the registered action."); } }; - const ledger = await openExistingLedger(statePath); - const session = ledger.session; - try { await checkAuthority(); } catch (error) { ledger.close(); throw error; } + const ledger = admitted ? undefined : await openExistingLedger(statePath!); + const session = ledger?.session; + try { await checkAuthority(); } catch (error) { ledger?.close(); throw error; } const validateReceipt = (raw: unknown, operationId: string, effect: PreparedOrderEffect): OrderReceipt => { const receipt = receiptSchema.parse(raw); if (receipt.operationId !== operationId || !same(receipt.effect, effect)) { @@ -108,18 +141,26 @@ export async function registerProtectedOrderAction(server: McpServer, options: R annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, }, async (input, context) => { try { - ledger.assertValid(); + ledger?.assertValid(); await authorize(context); await checkAuthority(); const effect = freeze(effectSchema.parse({ contract: "once-registered-order-v1", action: name, authority, resourceId, providerArgs: input.args, })); - const result = await withLocalProtectionSession(session, () => protectToolCall({ + if (resolveIntent && resolveKey && scope) { + const key = await resolveKey(context); + const binding = await resolveIntent(scope, key); + if (binding.scope !== scope || binding.intentKey !== key) throw new LocalProtectionError("INTENT_REQUIRED", "Trusted intent context mismatch."); + if (binding.operationId !== input.operationId) throw new LocalProtectionError("IDENTITY_CONFLICT", "Caller operation ID differs from the host-admitted logical intent."); + if (binding.effectFingerprint !== fingerprintEffect!({ tool: name, args: effect })) throw new LocalProtectionError("CONFLICT", "Host-admitted intent binds different effect-bearing inputs."); + } + const run = () => protectToolCall({ operationId: input.operationId, - statePath, + leaseMs, + ...(executionAuthority ? { authority: executionAuthority } : { statePath }), effect: { tool: name, args: effect }, execute: async ({ args }) => { - ledger.assertValid(); + ledger?.assertValid(); await checkAuthority(); // Identity is separate from provider arguments; this reviewed client // maps it to the provider's durable lookup reference. @@ -128,7 +169,7 @@ export async function registerProtectedOrderAction(server: McpServer, options: R return validateReceipt(receipt, input.operationId, args); }, reconcile: async ({ operationId, effect: prepared }) => { - ledger.assertValid(); + ledger?.assertValid(); await checkAuthority(); const observation = confirmationSchema.safeParse(await lookup(operationId)); await checkAuthority(); @@ -140,7 +181,8 @@ export async function registerProtectedOrderAction(server: McpServer, options: R // ABSENT, incomplete, stale, mismatched and malformed evidence all // remain UNKNOWN. The existing local primitive never redispatches. }, - })); + } as Parameters>[0]); + const result = await (session ? withLocalProtectionSession(session, run) : run()); const output = { operationId: input.operationId, status: "CONFIRMED", result }; return { content: [{ type: "text" as const, text: JSON.stringify(output) }], structuredContent: output }; } catch (error) { @@ -152,8 +194,8 @@ export async function registerProtectedOrderAction(server: McpServer, options: R } }); } catch (error) { - ledger.close(); + ledger?.close(); throw error; } - return { close: () => ledger.close() }; + return { close: () => ledger?.close() }; } diff --git a/mcp/test/fixtures/order-host.mjs b/mcp/test/fixtures/order-host.mjs index 8d7ad511..d1b72d3d 100644 --- a/mcp/test/fixtures/order-host.mjs +++ b/mcp/test/fixtures/order-host.mjs @@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { registerProtectedOrderAction } from '@once-agent/mcp/registered-action'; +import { writeFileSync } from 'node:fs'; const config = JSON.parse(process.env.ONCE_TEST_HOST); const authority = { provider: 'disposable-orders', accountId: config.accountId, environment: 'local-test' }; @@ -16,19 +17,63 @@ const request = async (pathname, body) => { return response.json(); }; const server = new McpServer({ name: 'once-registered-order-fixture', version: '1' }); +const pools = []; +const metrics = { execution: { statements: 0, transactions: 0 }, witness: { statements: 0, transactions: 0 }, memory: {} }; +let intentAuthority; +if (config.shared) { + const { default: pg } = await import('pg'); + const { createPostgresContinuityWitness, createPostgresIntentAuthority } = await import('@once-agent/sdk'); + const pool = new pg.Pool({ connectionString: config.shared.executionUrl, connectionTimeoutMillis: 1000 }); + const witnessPool = new pg.Pool({ connectionString: config.shared.witnessUrl, connectionTimeoutMillis: 1000 }); + for (const [kind, p] of [['execution', pool], ['witness', witnessPool]]) { + p.on('error', () => {}); pools.push(p); + const connect = p.connect.bind(p); + p.connect = async () => { + const client = await connect(), query = client.query.bind(client); + return { query: async (...args) => { + metrics[kind].statements++; + if (String(args[0]).startsWith('BEGIN')) metrics[kind].transactions++; + const result = await query(...args); metrics.memory = process.memoryUsage(); return result; + }, release: client.release.bind(client) }; + }; + } + const witness = createPostgresContinuityWitness({ pool: witnessPool, expectedWitnessId: config.shared.witnessId, expectedSchemaVersion: 2 }); + const execution = createPostgresIntentAuthority({ pool, witness, authorityId: config.shared.authorityId, + expectedGeneration: config.shared.generation, expectedEpoch: config.shared.epoch, witnessTimeoutMs: 1000 }); + intentAuthority = { authority: execution, scope: config.shared.scope, resolveKey: async () => config.trustedIntentKey }; +} const registration = await registerProtectedOrderAction(server, { name: config.name, authority, resourceId: config.resourceId, - statePath: config.statePath, + ...(intentAuthority ? { intentAuthority } : { statePath: config.statePath }), + leaseMs: config.leaseMs, // The parent test owns this private stdio process and its credentials. This // grants that parent access; network deployments need real host admission. authorize: async () => { if (config.denyAdmission) throw Error('not admitted'); }, provider: { assertAuthority: () => request('/authority'), - create: (effect, operationId) => request('/orders', { effect, operationId }), + create: async (effect, operationId) => { + if (config.lookupOnly) throw Error('read-only recovery host never executes provider creates'); + if (config.beforeProviderMarker) { + writeFileSync(config.beforeProviderMarker, JSON.stringify({ pid: process.pid, operationId }), { flush: true }); + await new Promise(() => {}); + } + try { return await request('/orders', { effect, operationId }); } + catch (error) { + // Test-only crash barrier: the socket has failed, but Once has not yet + // received the exception. The parent kills this process at this marker. + if (config.afterAckLossMarker) { + writeFileSync(config.afterAckLossMarker, JSON.stringify({ pid: process.pid, operationId }), { flush: true }); + await new Promise(() => {}); + } + throw error; + } + }, lookup: operationId => request(`/orders/${encodeURIComponent(operationId)}`), }, }); process.on('exit', () => registration.close()); +process.on('exit', () => { if (config.metricsPath) writeFileSync(config.metricsPath, JSON.stringify(metrics, null, 2)); }); +process.stdin.on('end', () => { registration.close(); Promise.all(pools.map(p => p.end())).catch(() => {}); }); serveStdio(() => server); diff --git a/mcp/test/fixtures/order-provider.mjs b/mcp/test/fixtures/order-provider.mjs index 74244ff9..ea5969f6 100644 --- a/mcp/test/fixtures/order-provider.mjs +++ b/mcp/test/fixtures/order-provider.mjs @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto'; * Every successful create appends a durable provider-owned receipt. There is no * native deduplication: a raw duplicate POST really produces a second effect. */ -export async function startOrderProvider(logPath) { +export async function startOrderProvider(logPath, hooks = {}) { writeFileSync(logPath, ''); const token = randomUUID(); const modes = new Map(); @@ -25,7 +25,11 @@ export async function startOrderProvider(logPath) { if (req.url?.startsWith('/orders/') && req.method === 'GET') { counters.lookups++; const id = decodeURIComponent(req.url.slice(8)); - if (observations.has(id)) { reply(observations.get(id)); return; } + if (observations.has(id)) { + const observation = observations.get(id); + if (observation instanceof Error) { res.destroy(); return; } + reply(observation); return; + } const found = records().filter(x => x.operationId === id && x.effect.authority.accountId === accountId); reply(found.length === 1 ? { status: 'CONFIRMED', authoritative: true, complete: true, observedAt: Date.now(), receipt: found[0] } : { status: found.length ? 'UNKNOWN' : 'ABSENT' }); return; } @@ -41,7 +45,8 @@ export async function startOrderProvider(logPath) { const receipt = { providerReference: `order-${records().length + 1}`, status: 'created', operationId, effect }; appendFileSync(logPath, JSON.stringify(receipt) + '\n'); const fd = openSync(logPath, 'r+'); fsyncSync(fd); closeSync(fd); - if (mode === 'lost-ack') { res.destroy(); return; } + hooks.onCommit?.(receipt); + if (mode === 'lost-ack') { res.destroy(); hooks.onAckDropped?.(receipt); return; } if (mode === 'hold') { await new Promise(resolve => { holdResolve = resolve; }); } reply(mode === 'bad-receipt' ? { ...receipt, operationId: 'wrong-intent' } : receipt); return; } diff --git a/mcp/test/intent-capability.test.mjs b/mcp/test/intent-capability.test.mjs new file mode 100644 index 00000000..cf4c50f8 --- /dev/null +++ b/mcp/test/intent-capability.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as sdk from '@once-agent/sdk'; +import { McpServer } from '@modelcontextprotocol/server'; +import { registerProtectedOrderAction } from '@once-agent/mcp/registered-action'; +test('intent mode never silently downgrades on a legacy SDK or accepts both authorities', async () => { + let callbacks = 0; + const server = new McpServer({ name: 'intent-admission-denial', version: '1' }); + const options = { + authority: { provider: 'disposable-orders', accountId: 'account-A', environment: 'local-test' }, resourceId: 'orders-local', + statePath: process.cwd(), + intentAuthority: { authority: { contract: 'once-host-intent-v2', open: async () => { callbacks++; throw Error('must not open'); }, resolveIntent: async () => { callbacks++; throw Error('must not resolve'); } }, scope: 'scope', resolveKey: async () => 'slot' }, + authorize: async () => { callbacks++; }, provider: Object.fromEntries(['assertAuthority','create','lookup'].map(key => [key, async () => { callbacks++; throw Error('must not reach provider'); }])), + }; + await assert.rejects(registerProtectedOrderAction(server, options), { code: 'UNSUPPORTED_BOUNDARY' }); + if (sdk.HOST_INTENT_AUTHORITY_CONTRACT !== 'once-host-intent-v2') { + delete options.statePath; + await assert.rejects(registerProtectedOrderAction(server, options), { code: 'UNSUPPORTED_BOUNDARY' }); + } + assert.equal(callbacks, 0); +}); diff --git a/mcp/test/silent-chicken.test.mjs b/mcp/test/silent-chicken.test.mjs new file mode 100644 index 00000000..2b360e3b --- /dev/null +++ b/mcp/test/silent-chicken.test.mjs @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash, randomUUID } from 'node:crypto'; +import { watch, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { DatabaseSync } from 'node:sqlite'; +import pg from 'pg'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { EMPTY_HOST_AUTHORITY_DIGEST, admitPostgresIntent, createPostgresContinuityWitness, createPostgresIntentAuthority } from '@once-agent/sdk'; +import { startOrderProvider } from './fixtures/order-provider.mjs'; + +const executionUrl = process.env.ONCE_TEST_POSTGRES, witnessUrl = process.env.ONCE_TEST_WITNESS; +if (!executionUrl || !witnessUrl) throw Error('Two separately provisioned disposable PostgreSQL clusters required; no skip or SQLite fallback.'); +const root = process.env.ONCE_HARDENING_RESULTS ?? mkdtempSync(path.join(tmpdir(), 'once-hardening-')); +mkdirSync(root, { recursive: true }); +const events = [], scenarios = [], latencies = []; +const log = (event, data = {}) => events.push({ sequence: events.length + 1, at: new Date().toISOString(), event, ...JSON.parse(JSON.stringify(data)) }); +const order = { sku: 'SKU-1', quantity: 2, destinationId: 'address-1' }; +const effect = { tool: 'once_create_order', args: { contract: 'once-registered-order-v1', action: 'once_create_order', authority: { provider: 'disposable-orders', accountId: 'account-A', environment: 'local-test' }, resourceId: 'orders-local', providerArgs: order } }; +const input = (operationId, args = order) => ({ operationId, args }); +const hostPath = fileURLToPath(new URL('./fixtures/order-host.mjs', import.meta.url)); +function marker(filename) { + return new Promise((resolve, reject) => { + let observer; const timer = setTimeout(() => { observer?.close(); reject(Error('worker barrier timed out')); }, 10000); + const check = () => { if (existsSync(filename)) { try { const value = JSON.parse(readFileSync(filename)); clearTimeout(timer); observer?.close(); resolve(value); } catch {} } }; + observer = watch(path.dirname(filename), check); check(); + }); +} + +test('Silent Chicken: admitted intent and independent authority continuity', { timeout: 180000 }, async t => { + const admins = [new pg.Pool({ connectionString: executionUrl }), new pg.Pool({ connectionString: witnessUrl })]; + const names = [0, 1].map(() => `once_hardening_${randomUUID().replaceAll('-', '')}`); + for (let i = 0; i < 2; i++) await admins[i].query(`CREATE DATABASE ${names[i]}`); + const urls = [executionUrl, witnessUrl].map((base, i) => { const url = new URL(base); url.pathname = `/${names[i]}`; return url; }); + const pools = urls.map(url => new pg.Pool({ connectionString: url.href })); + const [pool, witnessPool] = pools; + // This is explicit test provisioning, distinct from runtime recovery. + const sqlRoot = process.env.ONCE_SQL_ROOT ?? fileURLToPath(new URL('../../sdk/typescript/sql/', import.meta.url)); + await pool.query(readFileSync(path.join(sqlRoot, 'execution-authority-v1.sql'), 'utf8')); + await pool.query(readFileSync(path.join(sqlRoot, 'execution-host-intents-v2.sql'), 'utf8')); + await witnessPool.query(readFileSync(path.join(sqlRoot, 'continuity-witness-v1.sql'), 'utf8')); + await witnessPool.query(readFileSync(path.join(sqlRoot, 'continuity-witness-v2.sql'), 'utf8')); + const witnessId = randomUUID(); + await witnessPool.query('INSERT INTO once_continuity.metadata VALUES(true,$1,2)', [witnessId]); + const roles = [0, 1, 2].map(() => `once_role_${randomUUID().replaceAll('-', '')}`); + for (let i = 0; i < roles.length; i++) await admins[i === 2 ? 1 : 0].query(`CREATE ROLE ${roles[i]} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD 'fixture-only-runtime'`); + for (const role of roles.slice(0, 2)) { + await pool.query(`GRANT USAGE ON SCHEMA once_execution TO ${role}`); + await pool.query(`GRANT SELECT,UPDATE(revision,state_digest) ON once_execution.authorities TO ${role}`); + await pool.query(`GRANT SELECT ON once_execution.host_intents TO ${role}`); + } + await pool.query(`GRANT SELECT,INSERT,UPDATE(state,result_json,owner,lease_until) ON once_execution.operations TO ${roles[0]}`); + await pool.query(`GRANT SELECT ON once_execution.operations TO ${roles[1]}`); + await pool.query(`GRANT INSERT ON once_execution.host_intents TO ${roles[1]}`); + await witnessPool.query(`GRANT USAGE ON SCHEMA once_continuity TO ${roles[2]}`); + await witnessPool.query(`GRANT SELECT,UPDATE(schema_version) ON once_continuity.metadata TO ${roles[2]}`); + await witnessPool.query(`GRANT SELECT,UPDATE(revision,state_digest) ON once_continuity.checkpoints TO ${roles[2]}`); + const runtimeUrls = [urls[0], urls[0], urls[1]].map((url, i) => { const copy = new URL(url); copy.username = roles[i]; copy.password = 'fixture-only-runtime'; return copy.href; }); + const [runtimePool, admissionPool, runtimeWitnessPool] = runtimeUrls.map(connectionString => new pg.Pool({ connectionString, connectionTimeoutMillis: 1000 })); + const allPools = [...pools, runtimePool, admissionPool, runtimeWitnessPool]; + for (const p of [...admins, ...allPools]) p.on('error', () => {}); + const witness = createPostgresContinuityWitness({ pool: runtimeWitnessPool, expectedWitnessId: witnessId, expectedSchemaVersion: 2 }); + const provider = await startOrderProvider(path.join(root, 'provider.jsonl'), { + onCommit: receipt => log('provider-commit-fsynced', { receipt }), + onAckDropped: receipt => log('provider-ack-dropped', { operationId: receipt.operationId }), + }); + const hosts = new Set(); let attempts = 0; + const stop = async h => { await h.client.close(); hosts.delete(h); }; + t.after(async () => { + for (const h of hosts) await stop(h); + await provider.close(); + writeFileSync(path.join(root, 'provider-counters.json'), JSON.stringify(provider.counters, null, 2)); + const snapshot = { authorities: (await pool.query('SELECT * FROM once_execution.authorities ORDER BY authority_id')).rows, + intents: (await pool.query('SELECT * FROM once_execution.host_intents ORDER BY authority_id,scope,intent_key')).rows, + operations: (await pool.query('SELECT * FROM once_execution.operations ORDER BY authority_id,id')).rows, + checkpoints: (await witnessPool.query('SELECT * FROM once_continuity.checkpoints ORDER BY authority_id')).rows }; + writeFileSync(path.join(root, 'authority-snapshot.json'), JSON.stringify(snapshot, null, 2)); + writeFileSync(path.join(root, 'events.jsonl'), events.map(x => JSON.stringify(x)).join('\n') + '\n'); + const sorted = latencies.map(x => x.ms).sort((a, b) => a - b); + writeFileSync(path.join(root, 'results.json'), JSON.stringify({ schema: 'once-silent-chicken-v1', runtime: process.version, platform: process.platform, + passed: scenarios.filter(x => x.status === 'PASS').length, failed: scenarios.filter(x => x.status === 'FAIL').length, scenarios, + success: scenarios.length === 12 && scenarios.every(x => x.status === 'PASS'), providerEffects: provider.records().length, + latencies, measuredMcpCallLatencyMs: { samples: sorted.length, min: sorted[0], median: sorted[Math.floor(sorted.length / 2)], p95: sorted[Math.floor(sorted.length * .95)] }, + driverMemory: process.memoryUsage(), scope: 'two local authenticated PostgreSQL clusters on one physical Windows host; restricted runtime/admission/witness roles; isolated non-idempotent provider' }, null, 2)); + for (const p of allPools) await p.end(); + for (let i = 0; i < 2; i++) await admins[i].query(`DROP DATABASE ${names[i]}`); + for (let i = 0; i < roles.length; i++) await admins[i === 2 ? 1 : 0].query(`DROP ROLE ${roles[i]}`); + for (const p of admins) await p.end(); + console.log(`Hardening evidence: ${root}`); + }); + async function provision(key = 'trusted-task/slot-A') { + const authorityId = randomUUID(); + await pool.query("INSERT INTO once_execution.authorities VALUES($1,2,'generation-1',1,0,$2)", [authorityId, EMPTY_HOST_AUTHORITY_DIGEST]); + await witnessPool.query("INSERT INTO once_continuity.checkpoints VALUES($1,'generation-1',1,0,$2)", [authorityId, EMPTY_HOST_AUTHORITY_DIGEST]); + const base = { authorityId, expectedGeneration: 'generation-1', expectedEpoch: '1', witness }; + const begin = performance.now(); + const binding = await admitPostgresIntent({ ...base, pool: admissionPool }, { scope: 'host-scope', intentKey: key, effect }); + log('host-intent-admitted', { binding, admissionMs: performance.now() - begin }); + return { ...base, binding, key, runtime: createPostgresIntentAuthority({ ...base, pool: runtimePool }), + shared: { executionUrl: runtimeUrls[0], witnessUrl: runtimeUrls[2], witnessId, authorityId, generation: 'generation-1', epoch: '1', scope: 'host-scope' } }; + } + async function connect(cfg, extras = {}) { + const attemptId = `replacement-${++attempts}-${randomUUID()}`, sessionId = randomUUID(); + const config = { providerUrl: provider.url, token: provider.token, accountId: 'account-A', resourceId: 'orders-local', shared: cfg.shared, trustedIntentKey: cfg.key, leaseMs: 300, + metricsPath: path.join(root, `${attemptId}-metrics.json`), ...extras }; + const transport = new StdioClientTransport({ command: process.execPath, args: [hostPath], env: { ...process.env, ONCE_TEST_HOST: JSON.stringify(config) }, stderr: 'pipe' }); + let diagnostics = ''; transport.stderr.on('data', chunk => { diagnostics += chunk; }); + const send = transport.send.bind(transport); + transport.send = message => { if (message.method === 'tools/call') log('mcp-request', { attemptId, sessionId, pid: transport.pid, requestId: message.id, operationId: message.params.arguments.operationId }); return send(message); }; + const client = new Client({ name: attemptId, version: '1' }); + const handle = { client, transport, attemptId, sessionId, call: async x => { + const begin = performance.now(); + const response = await client.callTool({ name: 'once_create_order', arguments: x }, { timeout: 10000 }); + const result = JSON.parse(response.content[0].text); assert.deepEqual(response.structuredContent, result); + const ms = performance.now() - begin; latencies.push({ status: result.status, ms }); + log('mcp-outcome', { attemptId, sessionId, operationId: x.operationId, result, ms }); return result; + } }; + hosts.add(handle); + try { await client.connect(transport); await client.ping(); return handle; } + catch { await stop(handle); throw Error(`Fixture host startup rejected: ${diagnostics}`); } + } + const counts = () => ({ posts: provider.counters.posts, effects: provider.records().length }); + const noWrites = async fn => { const before = counts(); await fn(); assert.deepEqual(counts(), before); }; + const oneEffect = id => { assert.equal(provider.records().filter(x => x.operationId === id).length, 1); }; + const expire = async cfg => { + for (let i = 0; i < 100; i++) { + const { rows } = await pool.query('SELECT lease_until <= floor(extract(epoch FROM clock_timestamp())*1000) AS expired FROM once_execution.operations WHERE authority_id=$1 AND id=$2', [cfg.authorityId, cfg.binding.operationId]); + if (rows[0]?.expired) return; + await delay(20); + } + throw Error('real database lease did not expire'); + }; + const check = async (id, title, fn) => t.test(`${id}: ${title}`, async () => { + const row = { id, title, status: 'RUNNING' }; scenarios.push(row); log('scenario-start', { id }); + try { await fn(); row.status = 'PASS'; } catch (error) { row.status = 'FAIL'; row.error = error.message; throw error; } + finally { log('scenario-end', { ...row, ...counts() }); } + }); + + await check('H01', 'T06 fresh planner ID denied; separately admitted identical intent executes', async () => { + const cfg = await provision(); let host = await connect(cfg); + provider.modes.set(cfg.binding.operationId, 'lost-ack'); provider.observations.set(cfg.binding.operationId, { status: 'UNKNOWN' }); + assert.equal((await host.call(input(cfg.binding.operationId))).code, 'UNKNOWN'); await stop(host); + host = await connect(cfg); + await noWrites(async () => { + assert.equal((await host.call(input(`planner-${randomUUID()}`))).code, 'IDENTITY_CONFLICT'); + assert.equal((await host.call(input(cfg.binding.operationId))).code, 'UNKNOWN'); + const unknownSlot = await connect({ ...cfg, key: 'planner-invented-slot' }); + assert.equal((await unknownSlot.call(input(`planner-${randomUUID()}`))).code, 'INTENT_REQUIRED'); + }); + oneEffect(cfg.binding.operationId); + const next = await admitPostgresIntent({ ...cfg, pool: admissionPool }, { scope: 'host-scope', intentKey: 'trusted-task/intentional-new-slot', effect }); + log('separately-authorized-new-intent', { binding: next }); + assert.notEqual(next.operationId, cfg.binding.operationId); + const intentional = await connect({ ...cfg, key: next.intentKey }); + assert.equal((await intentional.call(input(next.operationId))).status, 'CONFIRMED'); oneEffect(next.operationId); + }); + await check('H02', 'T07 empty replacement execution authority denied; matching original history recovers', async () => { + const cfg = await provision(); let host = await connect(cfg); + assert.equal((await host.call(input(cfg.binding.operationId))).status, 'CONFIRMED'); await stop(host); + const meta = (await pool.query('SELECT * FROM once_execution.authorities WHERE authority_id=$1', [cfg.authorityId])).rows[0]; + const operation = (await pool.query('SELECT * FROM once_execution.operations WHERE authority_id=$1', [cfg.authorityId])).rows[0]; + // Strong partial-restore attack: preserve CURRENT metadata/revision/root + // but remove operations. A revision-only witness incorrectly accepts this. + await pool.query('DELETE FROM once_execution.operations WHERE authority_id=$1', [cfg.authorityId]); + host = await connect(cfg); + await noWrites(async () => assert.equal((await host.call(input(cfg.binding.operationId))).code, 'CONTINUITY_LOST')); + // Rehashing the replacement locally is also insufficient: the separately + // authenticated witness still pins the original contents, not this checksum. + const forgedRoot = createHash('sha256').update(JSON.stringify({ contract: 'once-authority-state-v2', operations: [], intents: [[cfg.binding.scope,cfg.key,cfg.binding.operationId,cfg.binding.effectFingerprint]] })).digest('hex'); + await pool.query('UPDATE once_execution.authorities SET state_digest=$2 WHERE authority_id=$1', [cfg.authorityId, forgedRoot]); + await noWrites(async () => assert.equal((await host.call(input(cfg.binding.operationId))).code, 'CONTINUITY_LOST')); + await pool.query('UPDATE once_execution.authorities SET revision=0 WHERE authority_id=$1', [cfg.authorityId]); + await noWrites(async () => assert.equal((await host.call(input(cfg.binding.operationId))).code, 'CONTINUITY_LOST')); + await stop(host); + // Explicit fixture restoration of the EXACT original execution snapshot. + await pool.query('UPDATE once_execution.authorities SET revision=$2,state_digest=$3 WHERE authority_id=$1', [cfg.authorityId, meta.revision, meta.state_digest]); + await pool.query('INSERT INTO once_execution.operations(authority_id,id,fingerprint,state,result_json,owner,lease_until) VALUES($1,$2,$3,$4,$5,$6,$7)', [operation.authority_id, operation.id, operation.fingerprint, operation.state, operation.result_json, operation.owner, operation.lease_until]); + host = await connect(cfg); + await noWrites(async () => assert.equal((await host.call(input(cfg.binding.operationId))).status, 'CONFIRMED')); + oneEffect(cfg.binding.operationId); + }); + await check('H03', 'commit/drop acknowledgement/real kill/repeated replacements/read-only reconciliation', async () => { + const cfg = await provision(); const file = path.join(root, 'commit-crash-marker.json'); + provider.modes.set(cfg.binding.operationId, 'lost-ack'); provider.observations.set(cfg.binding.operationId, { status: 'UNKNOWN' }); + const first = await connect(cfg, { afterAckLossMarker: file }); + const barrier = marker(file), pending = first.call(input(cfg.binding.operationId)).catch(() => undefined); + const observed = await barrier; process.kill(first.transport.pid, 'SIGKILL'); await pending; await stop(first); + log('worker-killed-after-provider-commit', observed); oneEffect(cfg.binding.operationId); await expire(cfg); + await noWrites(async () => { + for (let i = 0; i < 3; i++) { const replacement = await connect(cfg); const r = await replacement.call(input(cfg.binding.operationId)); assert.equal(r.code, 'UNKNOWN'); assert.equal(r.retryAllowed, false); await stop(replacement); } + provider.observations.delete(cfg.binding.operationId); const recovery = await connect(cfg); + const confirmed = await recovery.call(input(cfg.binding.operationId)); assert.equal(confirmed.status, 'CONFIRMED'); + assert.deepEqual(confirmed.result, provider.records().find(x => x.operationId === cfg.binding.operationId)); + await stop(recovery); const replay = await connect(cfg); const reads = provider.counters.lookups; + assert.deepEqual(await replay.call(input(cfg.binding.operationId)), confirmed); assert.equal(provider.counters.lookups, reads); + }); + }); + await check('H04', 'crash after reserved authority before provider effect stays UNKNOWN, not guessed success', async () => { + const cfg = await provision(); const file = path.join(root, 'before-provider-marker.json'); + const first = await connect(cfg, { beforeProviderMarker: file }); + const before = counts(); const barrier = marker(file), pending = first.call(input(cfg.binding.operationId)).catch(() => undefined); + await barrier; process.kill(first.transport.pid, 'SIGKILL'); await pending; await stop(first); assert.deepEqual(counts(), before); + await expire(cfg); const replacement = await connect(cfg); + await noWrites(async () => { assert.equal((await replacement.call(input(cfg.binding.operationId))).code, 'UNKNOWN'); }); + const row = (await pool.query('SELECT state FROM once_execution.operations WHERE authority_id=$1 AND id=$2', [cfg.authorityId, cfg.binding.operationId])).rows[0]; + assert.equal(row.state, 'UNKNOWN'); assert.equal(provider.records().filter(x => x.operationId === cfg.binding.operationId).length, 0); + }); + await check('H05', 'host-admitted intent alone is not a receipt; unused admission can execute once', async () => { + const cfg = await provision(); const first = await connect(cfg); process.kill(first.transport.pid, 'SIGKILL'); await stop(first); // real termination before any call/reservation + assert.equal((await pool.query('SELECT count(*)::int AS n FROM once_execution.operations WHERE authority_id=$1', [cfg.authorityId])).rows[0].n, 0); + const replacement = await connect(cfg); assert.equal((await replacement.call(input(cfg.binding.operationId))).status, 'CONFIRMED'); oneEffect(cfg.binding.operationId); + }); + await check('H06', 'simultaneous fresh workers have one execution owner', async () => { + const cfg = await provision(); const hosts = await Promise.all(Array.from({ length: 6 }, () => connect(cfg, { leaseMs: 5000 }))); + const results = await Promise.all(hosts.map(h => h.call(input(cfg.binding.operationId)))); + assert.ok(results.some(x => x.status === 'CONFIRMED')); assert.ok(results.every(x => x.status === 'CONFIRMED' || x.code === 'IN_FLIGHT')); + oneEffect(cfg.binding.operationId); + }); + await check('H07', 'SKU/quantity/destination/resource/account/tool drift remain CONFLICT', async () => { + const cfg = await provision(); const host = await connect(cfg); assert.equal((await host.call(input(cfg.binding.operationId))).status, 'CONFIRMED'); + await noWrites(async () => { + for (const args of [{ ...order, sku: 'SKU-2' }, { ...order, quantity: 3 }, { ...order, destinationId: 'address-2' }]) assert.equal((await host.call(input(cfg.binding.operationId, args))).code, 'CONFLICT'); + const changed = await connect(cfg, { resourceId: 'other-resource' }); assert.equal((await changed.call(input(cfg.binding.operationId))).code, 'CONFLICT'); + provider.setAccount('account-B'); const account = await connect(cfg, { accountId: 'account-B' }); assert.equal((await account.call(input(cfg.binding.operationId))).code, 'CONFLICT'); provider.setAccount('account-A'); + const renamed = await connect(cfg, { name: 'once_other_order' }); + const response = await renamed.client.callTool({ name: 'once_other_order', arguments: input(cfg.binding.operationId) }); assert.equal(JSON.parse(response.content[0].text).code, 'CONFLICT'); + }); + }); + await check('H08', 'socket-failed, ABSENT and forged/mismatched evidence cannot confirm', async () => { + const cfg = await provision(); const host = await connect(cfg); + provider.modes.set(cfg.binding.operationId, 'lost-ack'); assert.equal((await host.call(input(cfg.binding.operationId))).code, 'UNKNOWN'); + const receipt = provider.records().find(x => x.operationId === cfg.binding.operationId); + const valid = { status: 'CONFIRMED', authoritative: true, complete: true, observedAt: Date.now(), receipt }; + await noWrites(async () => { + for (const observation of [new Error('socket failed'), { status: 'ABSENT' }, { status: 'UNKNOWN' }, { ...valid, authoritative: false }, { ...valid, complete: false }, { ...valid, observedAt: Date.now() - 60000 }, { ...valid, receipt: { ...receipt, operationId: 'forged' } }, { ...valid, receipt: { ...receipt, effect: { ...receipt.effect, providerArgs: { ...order, quantity: 3 } } } }]) { + provider.observations.set(cfg.binding.operationId, observation); assert.equal((await host.call(input(cfg.binding.operationId))).code, 'UNKNOWN'); + } + }); + oneEffect(cfg.binding.operationId); + }); + await check('H09', 'missing/rewound/mismatched witness and unavailable authority fail closed', async () => { + const cfg = await provision(); const host = await connect(cfg); + const cp = (await witnessPool.query('SELECT * FROM once_continuity.checkpoints WHERE authority_id=$1', [cfg.authorityId])).rows[0]; + await witnessPool.query('DELETE FROM once_continuity.checkpoints WHERE authority_id=$1', [cfg.authorityId]); + await noWrites(async () => assert.equal((await host.call(input(cfg.binding.operationId))).code, 'CONTINUITY_LOST')); + await witnessPool.query('INSERT INTO once_continuity.checkpoints VALUES($1,$2,$3,0,$4)', [cp.authority_id, cp.generation, cp.epoch, cp.state_digest]); + await noWrites(async () => assert.equal((await host.call(input(cfg.binding.operationId))).code, 'CONTINUITY_LOST')); + await witnessPool.query('UPDATE once_continuity.checkpoints SET revision=$2,generation=$3 WHERE authority_id=$1', [cfg.authorityId, cp.revision, 'wrong-generation']); + await noWrites(async () => assert.equal((await host.call(input(cfg.binding.operationId))).code, 'CONTINUITY_LOST')); + await witnessPool.query('UPDATE once_continuity.checkpoints SET revision=$2,generation=$3 WHERE authority_id=$1', [cfg.authorityId, cp.revision, cp.generation]); + const unavailable = await connect({ ...cfg, shared: { ...cfg.shared, executionUrl: 'postgresql://unused:unused@127.0.0.1:1/missing' } }); + await noWrites(async () => assert.equal((await unavailable.call(input(cfg.binding.operationId))).code, 'STATE_UNAVAILABLE')); + const unavailableWitness = await connect({ ...cfg, shared: { ...cfg.shared, witnessUrl: 'postgresql://unused:unused@127.0.0.1:1/missing' } }); + await noWrites(async () => assert.equal((await unavailableWitness.call(input(cfg.binding.operationId))).code, 'CONTINUITY_UNAVAILABLE')); + assert.equal((await host.call(input(cfg.binding.operationId))).status, 'CONFIRMED'); oneEffect(cfg.binding.operationId); + }); + await check('H10', 'missing/corrupt intent never recreates admission; runtime cannot provision/delete', async () => { + const cfg = await provision(); const host = await connect(cfg); + await noWrites(async () => { + for (const sql of ['DELETE FROM once_execution.host_intents', 'UPDATE once_execution.host_intents SET operation_id=operation_id', "INSERT INTO once_execution.host_intents VALUES('x','x','x','x','x')", 'UPDATE once_execution.operations SET fingerprint=fingerprint', 'TRUNCATE once_execution.operations', 'DROP SCHEMA once_execution CASCADE']) await assert.rejects(runtimePool.query(sql), { code: '42501' }); + await assert.rejects(admitPostgresIntent({ ...cfg, pool: runtimePool }, { scope: 'host-scope', intentKey: 'forged-admission', effect }), { code: 'STATE_UNAVAILABLE' }); + await pool.query("UPDATE once_execution.host_intents SET operation_id='corrupt' WHERE authority_id=$1", [cfg.authorityId]); + assert.equal((await host.call(input(cfg.binding.operationId))).code, 'CONTINUITY_LOST'); + await pool.query('DELETE FROM once_execution.host_intents WHERE authority_id=$1', [cfg.authorityId]); + assert.equal((await host.call(input(cfg.binding.operationId))).code, 'CONTINUITY_LOST'); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM once_execution.host_intents WHERE authority_id=$1', [cfg.authorityId])).rows[0].n, 0); + }); + }); + await check('H11', 'changed trusted admission conflicts; ambiguous/missing scope is denied', async () => { + const cfg = await provision(); const before = counts(); + await assert.rejects(admitPostgresIntent({ ...cfg, pool: admissionPool }, { scope: 'host-scope', intentKey: cfg.key, effect: { ...effect, args: { ...effect.args, providerArgs: { ...order, quantity: 3 } } } }), { code: 'CONFLICT' }); + await assert.rejects(cfg.runtime.resolveIntent('', cfg.key), { code: 'INTENT_REQUIRED' }); + await assert.rejects(cfg.runtime.resolveIntent('scope', 'ambiguous\ud800'), { code: 'INTENT_REQUIRED' }); + await assert.rejects(admitPostgresIntent({ ...cfg, pool: admissionPool }, null), { code: 'INTENT_REQUIRED' }); + await assert.rejects(cfg.runtime.resolveIntent('other-scope', cfg.key), { code: 'INTENT_REQUIRED' }); + assert.deepEqual(counts(), before); + }); + await check('H12', 'local replacement SQLite cannot change the independently held execution authority', async () => { + const cfg = await provision(); let host = await connect(cfg); assert.equal((await host.call(input(cfg.binding.operationId))).status, 'CONFIRMED'); await stop(host); + // The protected configuration does not consult or initialize this untrusted + // worker-local checkpoint. Its replacement supplies no execution permission. + const file = path.join(root, 'replaced-worker-checkpoint.sqlite'); + const db = new DatabaseSync(file); db.exec('CREATE TABLE local_operations(id TEXT PRIMARY KEY)'); db.close(); + host = await connect(cfg); await noWrites(async () => assert.equal((await host.call(input(cfg.binding.operationId))).status, 'CONFIRMED')); oneEffect(cfg.binding.operationId); + }); +}); diff --git a/mcp/test/summa42.test.mjs b/mcp/test/summa42.test.mjs new file mode 100644 index 00000000..49f6f050 --- /dev/null +++ b/mcp/test/summa42.test.mjs @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomUUID, createHash } from 'node:crypto'; +import { watch, mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { DatabaseSync } from 'node:sqlite'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { createLocalProtectionSession } from '@once-agent/sdk/connect'; +import { protectToolCall } from '@once-agent/sdk'; +import { startOrderProvider } from './fixtures/order-provider.mjs'; + +const hostPath = fileURLToPath(new URL('./fixtures/order-host.mjs', import.meta.url)); +const root = process.env.ONCE_SUMMA42_RESULTS ?? mkdtempSync(path.join(tmpdir(), 'once-summa42-')); +mkdirSync(root, { recursive: true }); +const report = { schema: 'once-summa42-evidence-v1', startedAt: new Date().toISOString(), runtime: process.version, + platform: process.platform, arch: process.arch, tests: [], scope: 'controlled loopback provider; same-machine SQLite; no Summa42 runtime integration' }; +const events = []; +function record(event, data = {}) { + events.push({ sequence: events.length + 1, at: new Date().toISOString(), event, ...data }); +} +const order = { sku: 'SKU-1', quantity: 2, destinationId: 'address-1' }; +const input = (operationId, args = order) => ({ operationId, args }); +const output = response => { + const parsed = JSON.parse(response.content[0].text); + assert.deepEqual(response.structuredContent, parsed); + return parsed; +}; +const ledgerRows = statePath => { + const db = new DatabaseSync(statePath, { readOnly: true }); + try { return db.prepare('SELECT id,fingerprint,state,result_json,lease_until FROM local_operations ORDER BY id').all().map(x => ({ ...x })); } + finally { db.close(); } +}; +function waitForFile(filename) { + return new Promise((resolve, reject) => { + let watcher; + const timer = setTimeout(() => { watcher?.close(); reject(Error('acknowledgement-loss barrier timed out')); }, 15000); + const check = () => { + if (!existsSync(filename)) return; + try { + const data = JSON.parse(readFileSync(filename, 'utf8')); + clearTimeout(timer); watcher?.close(); resolve(data); + } catch { /* A write event can precede completion; the next event retries. */ } + }; + watcher = watch(path.dirname(filename), check); check(); + }); +} +async function fixture(t, name) { + const dir = path.join(root, name); mkdirSync(dir, { recursive: true }); + const statePath = path.join(dir, 'once.sqlite'); + const initial = createLocalProtectionSession(statePath); await initial.databaseForCall(); initial.close(); + const journalPath = path.join(dir, 'provider.jsonl'); + const provider = await startOrderProvider(journalPath, { + onCommit: receipt => record('provider-commit-fsynced', { test: name, receipt }), + onAckDropped: receipt => record('provider-acknowledgement-dropped', { test: name, operationId: receipt.operationId }), + }); + const hosts = new Set(); + let attemptNumber = 0; + const connect = async (overrides = {}) => { + const attemptId = `attempt-${++attemptNumber}-${randomUUID()}`, sessionId = randomUUID(); + const config = { providerUrl: provider.url, token: provider.token, statePath, accountId: 'account-A', resourceId: 'orders-local', ...overrides }; + const transport = new StdioClientTransport({ command: process.execPath, args: [hostPath], env: { ...process.env, ONCE_TEST_HOST: JSON.stringify(config) }, stderr: 'pipe' }); + let diagnostics = ''; + transport.stderr.on('data', chunk => { diagnostics += chunk; }); + const send = transport.send.bind(transport); + transport.send = async message => { + if (message.method === 'tools/call') record('mcp-request', { test: name, attemptId, sessionId, pid: transport.pid, requestId: message.id, operationId: message.params.arguments.operationId }); + return send(message); + }; + const client = new Client({ name: attemptId, version: '1' }); + const handle = { client, transport, attemptId, sessionId, call: async x => { + const result = output(await client.callTool({ name: 'once_create_order', arguments: x }, { timeout: 15000 })); + record('mcp-outcome', { test: name, attemptId, sessionId, operationId: x.operationId, outcome: result }); + return result; + } }; + hosts.add(handle); + try { + await client.connect(transport); + // Real transport requests advance the JSON-RPC counter between attempts. + for (let i = 0; i < attemptNumber; i++) await client.ping(); + record('worker-started', { test: name, attemptId, sessionId, pid: transport.pid }); + return handle; + } catch (error) { + record('worker-startup-rejected', { test: name, attemptId, sessionId, stateFile: path.basename(config.statePath), diagnostic: diagnostics }); + await client.close(); hosts.delete(handle); throw new Error(`Worker startup failed: ${diagnostics}`, { cause: error }); + } + }; + const stop = async host => { await host.client.close(); hosts.delete(host); }; + const counts = () => ({ effects: provider.records().length, posts: provider.counters.posts }); + const noWrites = async fn => { const before = counts(); await fn(); assert.deepEqual(counts(), before); }; + t.after(async () => { + for (const host of hosts) await stop(host); + await provider.close(); + writeFileSync(path.join(dir, 'provider-counters.json'), JSON.stringify(provider.counters, null, 2)); + record('final-provider-count', { test: name, ...counts() }); + }); + return { dir, statePath, provider, connect, stop, counts, noWrites }; +} + +test('Summa42 #35 bounded external-effect evidence', { timeout: 180000 }, async t => { + t.after(() => { + report.finishedAt = new Date().toISOString(); + report.passed = report.tests.filter(x => x.status === 'PASS').length; + report.failed = report.tests.filter(x => x.status === 'FAIL').length; + report.success = report.failed === 0 && report.tests.length === 8; + writeFileSync(path.join(root, 'events.jsonl'), events.map(x => JSON.stringify(x)).join('\n') + '\n'); + report.eventsSha256 = createHash('sha256').update(readFileSync(path.join(root, 'events.jsonl'))).digest('hex'); + writeFileSync(path.join(root, 'results.json'), JSON.stringify(report, null, 2)); + console.log(`Evidence: ${root}`); + }); + const check = async (id, title, criteria, fn) => t.test(`${id}: ${title}`, async st => { + const entry = { id, title, criteria, status: 'RUNNING' }; report.tests.push(entry); + try { await fn(st); entry.status = 'PASS'; } + catch (error) { entry.status = 'FAIL'; entry.error = { name: error.name, message: error.message }; throw error; } + }); + + await check('T01', 'raw duplicate control commits twice', ['AC1', 'AC4'], async st => { + const f = await fixture(st, 'T01'); + const effect = { contract: 'once-registered-order-v1', action: 'once_create_order', authority: { provider: 'disposable-orders', accountId: 'account-A', environment: 'local-test' }, resourceId: 'orders-local', providerArgs: order }; + f.provider.modes.set('raw-control', 'lost-ack'); + for (let i = 0; i < 2; i++) await assert.rejects(fetch(`${f.provider.url}/orders`, { + method: 'POST', headers: { Authorization: `Bearer ${f.provider.token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ operationId: 'raw-control', effect }), signal: AbortSignal.timeout(5000), + })); + assert.deepEqual(f.counts(), { effects: 2, posts: 2 }); + }); + + await check('T02', 'commit, drop ack, kill, replacement, UNKNOWN, reconcile and replay', ['AC1', 'AC2', 'AC3', 'AC4', 'AC5'], async st => { + const f = await fixture(st, 'T02'); + const operationId = 'task-35:order:1'; + const intentPath = path.join(f.dir, 'intent.json'); + writeFileSync(intentPath, JSON.stringify({ operationId, args: order, statePath: f.statePath }), { flush: true }); + const persisted = () => { const { operationId, args } = JSON.parse(readFileSync(intentPath)); return { operationId, args }; }; + f.provider.modes.set(operationId, 'lost-ack'); + f.provider.observations.set(operationId, { status: 'UNKNOWN' }); + const marker = path.join(f.dir, 'ack-loss-barrier.json'); + const first = await f.connect({ afterAckLossMarker: marker }); + const barrier = waitForFile(marker); + const pending = first.call(persisted()).then(() => { throw Error('crashed call unexpectedly returned'); }, () => undefined); + const observed = await barrier; + assert.equal(observed.pid, first.transport.pid); assert.equal(observed.operationId, operationId); + assert.deepEqual(f.counts(), { effects: 1, posts: 1 }); + assert.equal(ledgerRows(f.statePath)[0].state, 'CLAIMED'); + record('worker-observed-ack-loss-before-once-return', { test: 'T02', ...observed }); + process.kill(first.transport.pid, 'SIGKILL'); await pending; await f.stop(first); + record('worker-killed-call-closed', { test: 'T02', pid: observed.pid }); + const replacement = await f.connect(); + assert.notEqual(first.attemptId, replacement.attemptId); assert.notEqual(first.sessionId, replacement.sessionId); + await f.noWrites(async () => assert.equal((await replacement.call(persisted())).code, 'IN_FLIGHT')); + const claim = ledgerRows(f.statePath)[0]; + record('abandoned-claim', { test: 'T02', row: claim }); + // Wait for the actual 30s lease. No SQL clock/state edits and no redispatch. + await delay(Math.max(0, claim.lease_until - Date.now() + 20)); + await f.noWrites(async () => { + for (let i = 0; i < 3; i++) { + const result = await replacement.call(persisted()); + assert.equal(result.code, 'UNKNOWN'); assert.equal(result.retryAllowed, false); + } + await f.stop(replacement); + const replanned = await f.connect(); + assert.equal((await replanned.call(persisted())).code, 'UNKNOWN'); + assert.equal(ledgerRows(f.statePath)[0].state, 'UNKNOWN'); + const exactReceipt = f.provider.records()[0]; + // Independent fsynced journal is asserted against the GET reconciliation result. + f.provider.observations.delete(operationId); + const truth = await (await fetch(`${f.provider.url}/orders/${encodeURIComponent(operationId)}`, { headers: { Authorization: `Bearer ${f.provider.token}` } })).json(); + assert.equal(truth.status, 'CONFIRMED'); assert.deepEqual(truth.receipt, exactReceipt); + record('independent-provider-get', { test: 'T02', truth }); + const before = f.provider.counters.lookups; + let recovered; + for (let i = 0; i < 5; i++) { + recovered = await replanned.call(persisted()); + if (recovered.status === 'CONFIRMED') break; + // A conservative lookup failure is not a new execution right. Permit + // bounded read-only re-observation, always with the original identity. + assert.equal(recovered.code, 'UNKNOWN'); assert.equal(recovered.retryAllowed, false); + } + assert.equal(recovered.status, 'CONFIRMED'); assert.deepEqual(recovered.result, exactReceipt); + assert.ok(f.provider.counters.lookups > before && f.provider.counters.lookups <= before + 5); + assert.equal(ledgerRows(f.statePath)[0].state, 'CONFIRMED'); + await f.stop(replanned); + f.provider.observations.set(operationId, { status: 'UNKNOWN' }); + const final = await f.connect(); + const reads = f.provider.counters.lookups; + assert.deepEqual(await final.call(persisted()), recovered); + assert.equal(f.provider.counters.lookups, reads); + record('final-ledger', { test: 'T02', rows: ledgerRows(f.statePath) }); + }); + const requests = events.filter(x => x.test === 'T02' && x.event === 'mcp-request'); + assert.notEqual(requests[0].requestId, requests[1].requestId); + assert.deepEqual([...new Set(requests.map(x => x.operationId))], [operationId]); + }); + + await check('T03', 'effect drift is CONFLICT before provider write', ['AC2', 'AC5'], async st => { + const f = await fixture(st, 'T03'); const host = await f.connect(); + f.provider.modes.set('drift', 'lost-ack'); f.provider.observations.set('drift', { status: 'UNKNOWN' }); + assert.equal((await host.call(input('drift'))).code, 'UNKNOWN'); + await f.noWrites(async () => { + for (const args of [{ ...order, sku: 'SKU-2' }, { ...order, quantity: 3 }, { ...order, destinationId: 'address-2' }]) assert.equal((await host.call(input('drift', args))).code, 'CONFLICT'); + for (const overrides of [{ resourceId: 'other-resource' }, { name: 'once_other_order' }]) { + const changed = await f.connect(overrides); + const response = overrides.name ? output(await changed.client.callTool({ name: overrides.name, arguments: input('drift') })) : await changed.call(input('drift')); + assert.equal(response.code, 'CONFLICT'); await f.stop(changed); + } + f.provider.setAccount('account-B'); const other = await f.connect({ accountId: 'account-B' }); + assert.equal((await other.call(input('drift'))).code, 'CONFLICT'); + f.provider.setAccount('account-A'); + }); + }); + + await check('T04', 'unavailable, absent, malformed and mismatched truth remain UNKNOWN', ['AC2', 'AC3'], async st => { + const f = await fixture(st, 'T04'); const host = await f.connect(); + f.provider.modes.set('truth', 'lost-ack'); assert.equal((await host.call(input('truth'))).code, 'UNKNOWN'); + const receipt = f.provider.records()[0]; + const positive = { status: 'CONFIRMED', authoritative: true, complete: true, observedAt: Date.now(), receipt }; + await f.noWrites(async () => { + // A thrown lookup is covered by the SDK directly below; the MCP path tests bad envelopes. + const variants = [new Error('lookup unavailable'), null, { status: 'UNKNOWN' }, { status: 'NOT_FOUND' }, { status: 'ABSENT', authoritative: true }, + { status: 'CONFIRMED' }, { ...positive, authoritative: false }, { ...positive, complete: false }, + { ...positive, observedAt: Date.now() - 60000 }, { ...positive, observedAt: Date.now() + 60000 }, + { ...positive, receipt: { ...receipt, operationId: 'substituted-id' } }, + { ...positive, receipt: { ...receipt, effect: { ...receipt.effect, providerArgs: { ...order, quantity: 3 } } } }]; + for (const observation of variants) { + f.provider.observations.set('truth', observation); + const result = await host.call(input('truth')); assert.equal(result.code, 'UNKNOWN'); assert.equal(result.retryAllowed, false); + } + await assert.rejects(protectToolCall({ operationId: 'truth', statePath: path.join(f.dir, 'sdk.sqlite'), effect: { tool: 'sdk:truth', args: order }, execute: async () => { throw Error('transport unavailable'); } }), { code: 'UNKNOWN' }); + await assert.rejects(protectToolCall({ operationId: 'truth', statePath: path.join(f.dir, 'sdk.sqlite'), effect: { tool: 'sdk:truth', args: order }, execute: async () => { throw Error('must not dispatch'); }, reconcile: () => { throw Error('provider lookup unavailable'); } }), { code: 'UNKNOWN' }); + }); + await f.stop(host); + // Actually stop the provider, including authority GETs; fail closed at host admission. + await f.provider.close(); + await assert.rejects(f.connect()); + }); + + await check('T05', 'missing, corrupt, empty and schema-lost state reject replacement', ['AC2', 'AC3', 'AC5'], async st => { + const f = await fixture(st, 'T05'); let host = await f.connect(); + f.provider.modes.set('state-loss', 'lost-ack'); f.provider.observations.set('state-loss', { status: 'UNKNOWN' }); + assert.equal((await host.call(input('state-loss'))).code, 'UNKNOWN'); + await f.stop(host); + await f.noWrites(async () => { + const original = path.join(f.dir, 'original.sqlite'); renameSync(f.statePath, original); + await assert.rejects(f.connect()); assert.equal(existsSync(f.statePath), false); + renameSync(original, f.statePath); + for (const [name, data] of [['corrupt.sqlite', 'not sqlite'], ['empty.sqlite', '']]) { + const statePath = path.join(f.dir, name); writeFileSync(statePath, data); + await assert.rejects(f.connect({ statePath })); + } + host = await f.connect(); + const db = new DatabaseSync(f.statePath); db.exec('DROP TABLE local_operations'); db.close(); + assert.equal((await host.call(input('state-loss'))).code, 'STATE_UNAVAILABLE'); + assert.equal((await host.call(input('new-after-state-loss'))).code, 'STATE_UNAVAILABLE'); + await f.stop(host); await assert.rejects(f.connect()); + const inspect = new DatabaseSync(f.statePath, { readOnly: true }); + assert.equal(inspect.prepare("SELECT name FROM sqlite_schema WHERE name='local_operations'").get(), undefined); inspect.close(); + }); + }); + + await check('T06', 'identity injection rejected; fresh IDs are an explicit safety limit', ['AC3', 'AC5'], async st => { + const f = await fixture(st, 'T06'); const host = await f.connect(); + f.provider.modes.set('original', 'lost-ack'); f.provider.observations.set('original', { status: 'UNKNOWN' }); + assert.equal((await host.call(input('original'))).code, 'UNKNOWN'); + await f.noWrites(async () => { + for (const x of [input(''), { ...input('original'), attemptId: 'replacement' }, { operationId: 'original', args: { ...order, operationId: 'substitute' } }]) { + assert.equal((await host.client.callTool({ name: 'once_create_order', arguments: x })).isError, true); + } + assert.equal((await host.call(input('original'))).code, 'UNKNOWN'); + }); + // This MUST succeed: no SDK can infer that an arbitrary new ID is the same intent. + assert.equal((await host.call(input('new-id-for-same-payload'))).status, 'CONFIRMED'); + assert.deepEqual(f.counts(), { effects: 2, posts: 2 }); + record('demonstrated-limit-new-identity', { test: 'T06', originalOutcome: 'UNKNOWN', samePayloadNewIdOutcome: 'CONFIRMED', effects: 2, + requiredControl: 'host must retain/admit the canonical operation ID across planner iterations' }); + }); + + await check('T07', 'valid empty replacement ledger after restart is an explicit safety limit', ['AC3', 'AC5'], async st => { + const f = await fixture(st, 'T07'); const host = await f.connect(); + f.provider.modes.set('replaced-ledger', 'lost-ack'); f.provider.observations.set('replaced-ledger', { status: 'UNKNOWN' }); + assert.equal((await host.call(input('replaced-ledger'))).code, 'UNKNOWN'); await f.stop(host); + renameSync(f.statePath, path.join(f.dir, 'original.sqlite')); + // Deliberate operator misuse, confined to disposable state: schema-valid new authority. + const replacement = createLocalProtectionSession(f.statePath); await replacement.databaseForCall(); replacement.close(); + f.provider.modes.delete('replaced-ledger'); + const restarted = await f.connect(); + assert.equal((await restarted.call(input('replaced-ledger'))).status, 'CONFIRMED'); + assert.deepEqual(f.counts(), { effects: 2, posts: 2 }); + record('demonstrated-limit-valid-replacement-ledger', { test: 'T07', effects: 2, requiredControl: 'restore original authority; independent continuity witness needed to reject a valid replacement across process lifetimes' }); + }); + + await check('T08', 'confirmed receipt survives replacement attempt without lookup or execution', ['AC3', 'AC5'], async st => { + const f = await fixture(st, 'T08'); const first = await f.connect(); + const confirmed = await first.call(input('confirmed')); assert.equal(confirmed.status, 'CONFIRMED'); await f.stop(first); + await f.noWrites(async () => { + f.provider.observations.set('confirmed', { status: 'UNKNOWN' }); const second = await f.connect(); + const reads = f.provider.counters.lookups; + assert.deepEqual(await second.call(input('confirmed')), confirmed); + assert.equal(f.provider.counters.lookups, reads); + assert.equal((await second.call(input('confirmed', { ...order, quantity: 3 }))).code, 'CONFLICT'); + }); + }); +}); diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index c87c8958..086185f0 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@once-agent/sdk", - "version": "0.1.26", + "version": "0.1.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@once-agent/sdk", - "version": "0.1.26", + "version": "0.1.27", "license": "MIT", "dependencies": { "typescript": "^5.9.0" @@ -18,7 +18,8 @@ }, "devDependencies": { "@modelcontextprotocol/sdk": "1.32.1", - "@types/node": "^26.6.1" + "@types/node": "^26.6.1", + "pg": "8.16.3" }, "engines": { "node": ">=18" @@ -937,6 +938,103 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -947,6 +1045,49 @@ "node": ">=16.20.0" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -1199,6 +1340,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1315,6 +1466,16 @@ "dev": true, "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 2de7945b..9de21946 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@once-agent/sdk", - "version": "0.1.26", + "version": "0.1.27", "description": "Block duplicate consequential writes after lost acknowledgements; replay confirmed results and reconcile uncertain outcomes.", "type": "module", "main": "./dist/index.js", @@ -43,11 +43,14 @@ } }, "files": [ + "sql", "dist", "README.md", "dist-cjs" ], "scripts": { + "test:authority-kernel": "npm run build && node --test test/execution-store.test.mjs test/authority-canonical-security.test.mjs", + "test:shared-authority": "npm run build && node --test test/postgres-authority.test.mjs test/postgres-continuity-witness.test.mjs", "test:first10": "npm run build && node ../../scripts/generate-first10-proof.mjs --check && node --test ./test/first10.test.mjs", "test:first10-package": "npm run build && node ./scripts/first10-package-regression.mjs", "test:tool-call": "npm run build && node --test ./test/tool-call.test.mjs", @@ -82,7 +85,7 @@ "routing:cases": "node ./scripts/agent-routing-conformance.mjs --emit-cases", "test:routing-conformance": "node ./scripts/agent-routing-conformance-regression.mjs", "test:contract": "npm run build && tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --strict --types node ./scripts/sdk-contract-types.ts && node ./scripts/sdk-contract-runtime.mjs", - "test:release": "npm run test:first10 && npm run test:first10-package && npm run test:tool-call && npm run test:wrap-tool && npm run test:wrap-tool-package && npm run benchmark:scan && npm run benchmark:local-function-bridge && npm run test:local-function-transformer && npm run test:local-function-bridge-plan && npm run test:reviewed-local-semantics && npm run test:reviewed-local-caller-inventory && npm run test:reviewed-local-routing-preview && npm run test:reviewed-local-routing-review && npm run test:reviewed-local-routing-apply && npm run test:benchmark && npm run test:trace && npm run test:dataset-seal && npm run test:dataset-progress && npm run test:agent-discovery && npm run test:doctor && npm run test:monitor && npm run test:tool-discovery && npm run test:gateway && npm run test:reconciliation && npm run test:reconciliation-package && npm run test:routing-conformance && npm run test:scanner-declarations && npm run test:contract && npm run test:protect && npm run test:capabilities && npm run test:transformer && npm run test:binding && npm run test:patch-planner && npm run test:transformer-protect && npm run test:apply && npm run test:route-proof && npm run test:id-conformance && npm run test:consumer && npm run test:connect-package && npm run test:discovery-package && npm run test:gateway-package && npm run test:activation && npm run test:package && npm run test:runtime-setup && npm run test:connect", + "test:release": "npm run test:authority-kernel && npm run test:first10 && npm run test:first10-package && npm run test:tool-call && npm run test:wrap-tool && npm run test:wrap-tool-package && npm run benchmark:scan && npm run benchmark:local-function-bridge && npm run test:local-function-transformer && npm run test:local-function-bridge-plan && npm run test:reviewed-local-semantics && npm run test:reviewed-local-caller-inventory && npm run test:reviewed-local-routing-preview && npm run test:reviewed-local-routing-review && npm run test:reviewed-local-routing-apply && npm run test:benchmark && npm run test:trace && npm run test:dataset-seal && npm run test:dataset-progress && npm run test:agent-discovery && npm run test:doctor && npm run test:monitor && npm run test:tool-discovery && npm run test:gateway && npm run test:reconciliation && npm run test:reconciliation-package && npm run test:routing-conformance && npm run test:scanner-declarations && npm run test:contract && npm run test:protect && npm run test:capabilities && npm run test:transformer && npm run test:binding && npm run test:patch-planner && npm run test:transformer-protect && npm run test:apply && npm run test:route-proof && npm run test:id-conformance && npm run test:consumer && npm run test:connect-package && npm run test:discovery-package && npm run test:gateway-package && npm run test:activation && npm run test:package && npm run test:runtime-setup && npm run test:connect", "test:local": "npm run build && node --test ./test/local.test.mjs ./test/connect/github-issue-recovery.test.mjs ./test/connect/local-agent-tool.test.mjs ./test/connect/mcp-execution-boundary.test.mjs ./test/gateway/local-gateway-toolset.test.mjs ./test/gateway/openai-function-tools-gateway.test.mjs && node ./scripts/synthetic-proof-regression.mjs && node ./scripts/local-function-bridge-apply-regression.mjs && npm run test:reviewed-local-caller-inventory && npm run test:reviewed-local-routing-preview && npm run test:reviewed-local-routing-review && npm run test:reviewed-local-routing-apply && npm run test:reviewed-local-routed-application-proof && npm run test:local-function-bridge-cli && npm run test:reviewed-local-cli", "test:connect": "npm run build && node --test ./test/connect/*.test.mjs", "test:connect-package": "node ./scripts/connect-package-regression.mjs", @@ -134,7 +137,8 @@ }, "devDependencies": { "@modelcontextprotocol/sdk": "1.32.1", - "@types/node": "^26.6.1" + "@types/node": "^26.6.1", + "pg": "8.16.3" }, "bin": { "once": "dist/once-cli.js", diff --git a/sdk/typescript/sql/continuity-witness-v1.sql b/sdk/typescript/sql/continuity-witness-v1.sql new file mode 100644 index 00000000..da687b94 --- /dev/null +++ b/sdk/typescript/sql/continuity-witness-v1.sql @@ -0,0 +1,16 @@ +-- Explicit operator provisioning on a SEPARATE PostgreSQL cluster/restore domain. +-- No implicit repair or IF NOT EXISTS. Seed metadata/checkpoints out of band. +BEGIN; +CREATE SCHEMA once_continuity; +CREATE TABLE once_continuity.metadata ( + singleton boolean PRIMARY KEY CHECK(singleton), + witness_id text NOT NULL, + schema_version integer NOT NULL CHECK(schema_version=1) +); +CREATE TABLE once_continuity.checkpoints ( + authority_id text PRIMARY KEY, + generation text NOT NULL, + epoch bigint NOT NULL CHECK(epoch>=0), + revision bigint NOT NULL CHECK(revision>=0) +); +COMMIT; diff --git a/sdk/typescript/sql/continuity-witness-v2.sql b/sdk/typescript/sql/continuity-witness-v2.sql new file mode 100644 index 00000000..fec343cf --- /dev/null +++ b/sdk/typescript/sql/continuity-witness-v2.sql @@ -0,0 +1,7 @@ +-- Apply ONLY on the independently protected witness cluster, with explicit +-- trusted operator provisioning. Missing roots are never seeded by runtime. +BEGIN; +ALTER TABLE once_continuity.metadata DROP CONSTRAINT metadata_schema_version_check; +ALTER TABLE once_continuity.metadata ADD CHECK(schema_version IN (1,2)); +ALTER TABLE once_continuity.checkpoints ADD COLUMN state_digest text; +COMMIT; diff --git a/sdk/typescript/sql/execution-authority-v1.sql b/sdk/typescript/sql/execution-authority-v1.sql new file mode 100644 index 00000000..94c4b772 --- /dev/null +++ b/sdk/typescript/sql/execution-authority-v1.sql @@ -0,0 +1,27 @@ +-- EXPLICIT operator provisioning only. Never run automatically on SDK startup. +-- Intentionally no IF NOT EXISTS: partial/missing/incompatible state is an error. +-- Provision a new authority and its independent witness together, out of band. +BEGIN; +CREATE SCHEMA once_execution; +CREATE TABLE once_execution.authorities ( + authority_id text PRIMARY KEY, + schema_version integer NOT NULL CHECK (schema_version = 1), + generation text NOT NULL, + epoch bigint NOT NULL CHECK (epoch >= 0), + revision bigint NOT NULL CHECK (revision >= 0) +); +CREATE TABLE once_execution.operations ( + authority_id text NOT NULL REFERENCES once_execution.authorities(authority_id), + id text NOT NULL, + fingerprint text NOT NULL, + state text NOT NULL CHECK (state IN ('CLAIMED','UNKNOWN','CONFIRMED')), + result_json text, + owner text, + lease_until bigint, + PRIMARY KEY (authority_id,id), + CHECK ((state = 'CLAIMED' AND owner IS NOT NULL AND lease_until IS NOT NULL) + OR (state <> 'CLAIMED' AND owner IS NULL AND lease_until IS NULL)), + CHECK ((state = 'CONFIRMED' AND result_json IS NOT NULL) + OR (state <> 'CONFIRMED' AND result_json IS NULL)) +); +COMMIT; diff --git a/sdk/typescript/sql/execution-host-intents-v2.sql b/sdk/typescript/sql/execution-host-intents-v2.sql new file mode 100644 index 00000000..d11876f9 --- /dev/null +++ b/sdk/typescript/sql/execution-host-intents-v2.sql @@ -0,0 +1,16 @@ +-- Explicit opt-in migration; never run by runtime recovery. Requires operator +-- review and trusted seeding of schema_version=2/state_digest for each namespace. +BEGIN; +ALTER TABLE once_execution.authorities DROP CONSTRAINT authorities_schema_version_check; +ALTER TABLE once_execution.authorities ADD CHECK(schema_version IN (1,2)); +ALTER TABLE once_execution.authorities ADD COLUMN state_digest text; +CREATE TABLE once_execution.host_intents ( + authority_id text NOT NULL REFERENCES once_execution.authorities(authority_id), + scope text NOT NULL CHECK(length(scope)>0), + intent_key text NOT NULL CHECK(length(intent_key)>0), + operation_id text NOT NULL, + effect_fingerprint text NOT NULL, + PRIMARY KEY(authority_id,scope,intent_key), UNIQUE(authority_id,operation_id) +); +-- Runtime SELECT only on mappings. Separate admission role SELECT/INSERT. +COMMIT; diff --git a/sdk/typescript/src/execution-store.ts b/sdk/typescript/src/execution-store.ts new file mode 100644 index 00000000..cd74994b --- /dev/null +++ b/sdk/typescript/src/execution-store.ts @@ -0,0 +1,28 @@ +/** Storage seam for the existing protectLocal kernel; not a second dispatcher. */ +export interface ExecutionRow { + fingerprint: string; + state: "CLAIMED" | "UNKNOWN" | "CONFIRMED"; + result_json: string | null; + owner: string | null; + lease_until: number | null; +} + +export interface ExecutionStore { + /** Atomic insert-if-absent; expired claims become UNKNOWN, never re-owned. */ + reserve(id: string, fingerprint: string, owner: string, leaseMs: number): Promise<{ + dispatch: boolean; row?: ExecutionRow; + }>; + /** Must check current authority and the exact live claim before dispatch. */ + assertDispatch(id: string, fingerprint: string, owner: string): Promise; + markUnknown(id: string, fingerprint: string, owner: string): Promise; + /** Compare-and-set confirmation; false means execution authority was lost. */ + confirm(id: string, fingerprint: string, owner: string, result: string): Promise; + /** Only the kernel's validated authoritative provider observation reaches here. */ + reconcile(id: string, fingerprint: string, result: string): Promise; + close(): void; +} + +/** Trusted host-owned storage. Must never be selected from model/tool arguments. */ +export interface ExecutionAuthority { + open(): Promise; +} diff --git a/sdk/typescript/src/host-intent.ts b/sdk/typescript/src/host-intent.ts new file mode 100644 index 00000000..0ef990c7 --- /dev/null +++ b/sdk/typescript/src/host-intent.ts @@ -0,0 +1,32 @@ +import type { ExecutionAuthority } from "./execution-store.js"; +import { LocalProtectionError, snapshotLocalData } from "./local.js"; +import { fingerprintConnectPayload } from "./connect/binding.js"; +import type { ToolCallEffect } from "./tool-call.js"; +import { createHash } from "node:crypto"; + +export const HOST_INTENT_AUTHORITY_CONTRACT = "once-host-intent-v2" as const; +/** For explicitly authorized NEW empty namespaces only; never recovery seeding. */ +export const EMPTY_HOST_AUTHORITY_DIGEST = createHash("sha256").update(JSON.stringify({ contract: "once-authority-state-v2", operations: [], intents: [] })).digest("hex"); + +/** Created by a trusted host BEFORE tool execution, never by planner IDs/hashes. */ +export interface HostIntentBinding { + readonly scope: string; + readonly intentKey: string; + readonly operationId: string; + readonly effectFingerprint: string; +} +export interface HostIntentRequest { scope: string; intentKey: string; effect: ToolCallEffect } +export interface HostIntentAuthority extends ExecutionAuthority { + readonly contract: typeof HOST_INTENT_AUTHORITY_CONTRACT; + /** Lookup only. Missing history is a denial, never implicit admission. */ + resolveIntent(scope: string, intentKey: string): Promise; +} +export function hostIntentEffectFingerprint(effect: ToolCallEffect): string { + const value = snapshotLocalData(effect); + if (!value || Object.keys(value).sort().join(",") !== "args,tool" || + typeof value.tool !== "string" || !value.tool.trim() || !value.args || + typeof value.args !== "object" || Array.isArray(value.args)) { + throw new LocalProtectionError("INVALID_EFFECT", "Host intent needs the complete explicit tool effect."); + } + return fingerprintConnectPayload(value as unknown as Record); +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 50da9bf1..4e3c7f08 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1097,3 +1097,12 @@ export type { AgentTool, LocalAgentToolContract } from "./connect/local-agent-tool.js"; + +export type { ExecutionAuthority, ExecutionStore, ExecutionRow } from "./execution-store.js"; +export { createPostgresExecutionAuthority } from "./postgres-authority.js"; +export type { PostgresAuthorityOptions, AuthorityCheckpoint, ContinuityWitness, AuthoritySqlPool, AuthoritySqlClient } from "./postgres-authority.js"; +export { createPostgresContinuityWitness } from "./postgres-continuity-witness.js"; +export type { PostgresContinuityWitnessOptions } from "./postgres-continuity-witness.js"; +export { createPostgresIntentAuthority, admitPostgresIntent } from "./postgres-authority.js"; +export { HOST_INTENT_AUTHORITY_CONTRACT, EMPTY_HOST_AUTHORITY_DIGEST, hostIntentEffectFingerprint } from "./host-intent.js"; +export type { HostIntentAuthority, HostIntentBinding, HostIntentRequest } from "./host-intent.js"; diff --git a/sdk/typescript/src/local.ts b/sdk/typescript/src/local.ts index 308ef2c6..83387e58 100644 --- a/sdk/typescript/src/local.ts +++ b/sdk/typescript/src/local.ts @@ -5,6 +5,8 @@ import path from "node:path"; import { types } from "node:util"; import { canonicalizeConnectPayload, fingerprintConnectPayload } from "./connect/binding.js"; +import type { ExecutionAuthority, ExecutionRow, ExecutionStore } from "./execution-store.js"; + type JsonObject = Record; type LocalStatement = { @@ -43,6 +45,8 @@ export interface LocalProtectionOptions { payload: (...args: A) => JsonObject; /** Absolute or project-relative path. Defaults to .once/operations.sqlite. */ statePath?: string; + /** Explicit shared authority. Mutually exclusive with statePath; no fallback. */ + authority?: ExecutionAuthority; /** Authoritative provider lookup. It must never infer ABSENT from a timeout. */ reconcile?: (context: { id: string; payload: JsonObject }) => Promise> | LocalObservation; /** Time before an abandoned claim can be reconciled. No redispatch follows ABSENT. */ @@ -56,13 +60,15 @@ export class LocalProtectionError extends Error { } } -type Row = { - fingerprint: string; - state: "CLAIMED" | "UNKNOWN" | "CONFIRMED"; - result_json: string | null; - owner: string | null; - lease_until: number | null; -}; +function safeAuthorityFailure(cause: unknown): ErrorOptions | undefined { + const allowed = ["STATE_UNAVAILABLE", "STALE_AUTHORITY", "AUTHORITY_MISSING", "CONTINUITY_LOST", "CONTINUITY_UNAVAILABLE"]; + if (cause instanceof LocalProtectionError && allowed.includes(cause.code)) { + return { cause: new LocalProtectionError(cause.code, "Durable execution authority rejected confirmation.") }; + } + return undefined; +} + +type Row = ExecutionRow; function resolvedStatePath(value?: string): string { return path.resolve(value ?? path.join(process.cwd(), ".once", "operations.sqlite")); @@ -81,7 +87,7 @@ async function loadDatabaseSync(): Promise { const sqlite = await import("node:sqlite"); return sqlite.DatabaseSync as unknown as DatabaseSyncConstructor; } catch (cause) { - throw new LocalProtectionError("SQLITE_UNAVAILABLE", "Node SQLite is unavailable. Enable node:sqlite or use the hosted Once execution path; the operation was not dispatched.", { cause }); + throw new LocalProtectionError("SQLITE_UNAVAILABLE", "Node SQLite is unavailable. Enable node:sqlite or use the hosted Once execution path; the operation was not dispatched."); } } @@ -97,7 +103,7 @@ async function openLocalDatabase(statePath: string): Promise { if (db) { try { db.close(); } catch { /* Preserve the initialization error. */ } } - throw new LocalProtectionError("STATE_UNAVAILABLE", `Cannot open durable Once state at ${statePath}. The operation was not dispatched. Restore access to this same file before retrying.`, { cause }); + throw new LocalProtectionError("STATE_UNAVAILABLE", `Cannot open durable Once state at ${statePath}. The operation was not dispatched. Restore access to this same file before retrying.`); } } @@ -150,7 +156,7 @@ export class LocalProtectionSession { identity = stateFileIdentity(this.statePath); } catch (cause) { try { db.close(); } catch { /* Preserve state identity failure. */ } - throw new LocalProtectionError("STATE_UNAVAILABLE", `Cannot verify durable Once state at ${this.statePath}. No operation was dispatched.`, { cause }); + throw new LocalProtectionError("STATE_UNAVAILABLE", `Cannot verify durable Once state at ${this.statePath}. No operation was dispatched.`); } if (this.#closed) { try { db.close(); } catch { /* Session close is authoritative. */ } @@ -178,7 +184,7 @@ export class LocalProtectionSession { current = stateFileIdentity(this.statePath); } catch (cause) { this.invalidate(); - throw new LocalProtectionError("STATE_UNAVAILABLE", `Durable Once state at ${this.statePath} disappeared while the execution boundary was live. The boundary is invalidated; no operation was dispatched.`, { cause }); + throw new LocalProtectionError("STATE_UNAVAILABLE", `Durable Once state at ${this.statePath} disappeared while the execution boundary was live. The boundary is invalidated; no operation was dispatched.`); } if (!sameStateFile(this.#identity, current)) { @@ -310,15 +316,79 @@ function decodeResult(encoded: string): T { const validated = copyData(envelope) as { hasValue: boolean; value?: T }; return (validated.hasValue ? validated.value : undefined) as T; } catch (cause) { - throw new LocalProtectionError("STATE_UNAVAILABLE", "The stored local receipt is malformed. No operation was dispatched.", { cause }); + throw new LocalProtectionError("STATE_UNAVAILABLE", "The stored local receipt is malformed. No operation was dispatched."); } } +/** SQLite implementation of the same storage seam used by shared authority. */ +async function openLocalStore(statePath: string): Promise { + const contextual = localProtectionSessionContext.getStore(); + const shared = contextual?.statePath === statePath ? contextual : undefined; + const session = shared ?? createLocalProtectionSession(statePath); + const db = await session.databaseForCall(); + const read = (id: string) => db.prepare("SELECT fingerprint,state,result_json,owner,lease_until FROM local_operations WHERE id=?").get(id) as Row | undefined; + const transaction = (action: () => T): T => { + session.assertStateIdentity(); + try { + db.exec("BEGIN IMMEDIATE"); + const value = action(); + db.exec("COMMIT"); + return value; + } catch (cause) { + if (db.isTransaction) { + try { db.exec("ROLLBACK"); } catch { session.invalidate(); } + } + if (cause instanceof LocalProtectionError) throw cause; + throw new LocalProtectionError("STATE_UNAVAILABLE", "Durable execution state is unavailable; no fresh dispatch is authorized."); + } + }; + return { + async reserve(id, fingerprint, owner, leaseMs) { + return transaction(() => { + let row = read(id); + if (!row) { + db.prepare("INSERT INTO local_operations(id,fingerprint,state,result_json,owner,lease_until) VALUES(?,?,'CLAIMED',NULL,?,?)") + .run(id, fingerprint, owner, Date.now() + leaseMs); + return { dispatch: true }; + } + if (row.fingerprint === fingerprint && row.state === "CLAIMED" && row.lease_until! <= Date.now()) { + db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED'").run(id); + row = read(id)!; + } + return { dispatch: false, row }; + }); + }, + async assertDispatch(id, fingerprint, owner) { + transaction(() => { + const row = read(id); + if (row?.fingerprint !== fingerprint || row.state !== "CLAIMED" || row.owner !== owner) { + throw new LocalProtectionError("EXECUTION_RIGHT_LOST", "The execution claim is stale. No fresh dispatch is authorized."); + } + }); + }, + async markUnknown(id, fingerprint, owner) { + transaction(() => db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND fingerprint=? AND state='CLAIMED' AND owner=?").run(id, fingerprint, owner)); + }, + async confirm(id, fingerprint, owner, result) { + return transaction(() => db.prepare("UPDATE local_operations SET state='CONFIRMED',result_json=?,owner=NULL,lease_until=NULL WHERE id=? AND fingerprint=? AND state='CLAIMED' AND owner=?") + .run(result, id, fingerprint, owner).changes === 1); + }, + async reconcile(id, fingerprint, result) { + return transaction(() => { + db.prepare("UPDATE local_operations SET state='CONFIRMED',result_json=? WHERE id=? AND fingerprint=? AND state='UNKNOWN'").run(result, id, fingerprint); + return read(id); + }); + }, + close() { if (!shared) session.close(); }, + }; +} + /** - * Protect an existing async operation on one machine while keeping its call + * Protect an existing async operation while keeping its call * shape and dynamic receiver. Ordinary data arguments are snapshotted and * frozen; opaque handles keep their identity. SQLite coordinates processes - * sharing the same durable local file. + * sharing the same durable local file. An explicit authority replaces only the + * storage seam; execution and reconciliation decisions remain in this kernel. * Uncertain outcomes remain blocked; this wrapper never redispatches after * an ambiguous attempt, even if a lookup reports ABSENT. */ @@ -329,6 +399,10 @@ export function protectLocal( if (typeof operation !== "function" || typeof options?.id !== "function" || typeof options.payload !== "function") { throw new LocalProtectionError("INVALID_CONFIGURATION", "protectLocal requires an async operation plus id and payload functions. The id must survive retries; payload must include every effect-bearing input."); } + if (options.authority !== undefined && (options.statePath !== undefined || typeof options.authority?.open !== "function")) { + throw new LocalProtectionError("INVALID_CONFIGURATION", "Supply one explicit execution authority or a local statePath, never both."); + } + const openAuthority = options.authority?.open.bind(options.authority); const statePath = resolvedStatePath(options.statePath); const leaseMs = options.leaseMs ?? 30_000; if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0) { @@ -343,42 +417,20 @@ export function protectLocal( } const payload = copyData(options.payload(...callArgs)) as JsonObject; const fingerprint = fingerprintConnectPayload(payload); - const contextualSession = localProtectionSessionContext.getStore(); - const sharedSession = contextualSession?.statePath === statePath ? contextualSession : undefined; - const db = sharedSession - ? await sharedSession.databaseForCall() - : await openLocalDatabase(statePath); - const closeAfterCall = !sharedSession; - + const store = openAuthority + ? await openAuthority() + : await openLocalStore(statePath); try { const owner = randomUUID(); - let shouldDispatch = false; - let row: Row; - try { - db.exec("BEGIN IMMEDIATE"); - row = db.prepare("SELECT fingerprint,state,result_json,owner,lease_until FROM local_operations WHERE id=?").get(id) as Row; - if (!row) { - db.prepare("INSERT INTO local_operations(id,fingerprint,state,result_json,owner,lease_until) VALUES(?,?,'CLAIMED',NULL,?,?)") - .run(id, fingerprint, owner, Date.now() + leaseMs); - shouldDispatch = true; - } - db.exec("COMMIT"); - } catch (cause) { - if (db.isTransaction) { - try { db.exec("ROLLBACK"); } catch { sharedSession?.invalidate(); } - } - throw new LocalProtectionError("STATE_UNAVAILABLE", "Could not atomically claim durable Once state. The operation was not dispatched; retry with the same id after restoring state access.", { cause }); - } - + const { dispatch: shouldDispatch, row } = await store.reserve(id, fingerprint, owner, leaseMs); if (!shouldDispatch) { if (row!.fingerprint !== fingerprint) { throw new LocalProtectionError("CONFLICT", `Logical action ${id} has a different effect-bearing payload. Choose a new id only for a genuinely new action; no write was dispatched.`); } if (row!.state === "CONFIRMED") return decodeResult(row!.result_json!); - if (row!.state === "CLAIMED" && row!.lease_until! > Date.now()) { + if (row!.state === "CLAIMED") { throw new LocalProtectionError("IN_FLIGHT", `Logical action ${id} is still in flight. Wait and retry with the same id; do not call the underlying operation directly.`); } - db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED'").run(id); if (!options.reconcile) { throw new LocalProtectionError("UNKNOWN", `RETRY BLOCKED — ORIGINAL OUTCOME UNKNOWN. Operation ${id} may already have succeeded. Repeating it could create another external effect; no second write was dispatched. Next action: supply an authoritative read-only reconcile callback or investigate provider truth; retain the same identity and durable state.`); } @@ -386,7 +438,7 @@ export function protectLocal( try { observation = await options.reconcile({ id, payload: copyData(payload) as JsonObject }); } catch (cause) { - throw new LocalProtectionError("UNKNOWN", `Provider truth for ${id} is unavailable. No second write was dispatched.`, { cause }); + throw new LocalProtectionError("UNKNOWN", `Provider truth for ${id} is unavailable. No second write was dispatched.`); } if (observation?.state !== "CONFIRMED") { throw new LocalProtectionError("UNKNOWN", `Provider truth for ${id} did not confirm an effect. Local mode will not redispatch after an ambiguous attempt; investigate or use a provider idempotency integration.`); @@ -398,17 +450,14 @@ export function protectLocal( try { resultJson = encodeResult(observation.result); } catch (cause) { - throw new LocalProtectionError("INVALID_TRUTH", "CONFIRMED provider truth needs a JSON-safe result. No second write was dispatched.", { cause }); + throw new LocalProtectionError("INVALID_TRUTH", "CONFIRMED provider truth needs a JSON-safe result. No second write was dispatched."); } - const confirmation = db.prepare("UPDATE local_operations SET state='CONFIRMED',result_json=? WHERE id=? AND state='UNKNOWN'").run(resultJson, id); - if (confirmation.changes !== 1) { - const latest = db.prepare("SELECT fingerprint,state,result_json,owner,lease_until FROM local_operations WHERE id=?").get(id) as Row | undefined; - if (latest?.fingerprint === fingerprint && latest.state === "CONFIRMED" && latest.result_json !== null) { - return decodeResult(latest.result_json); - } - throw new LocalProtectionError("UNKNOWN", `State changed while reconciling ${id}. Retry with the same id; no second write was dispatched.`); + const latest = await store.reconcile(id, fingerprint, resultJson); + if (latest?.fingerprint === fingerprint && latest.state === "CONFIRMED" && latest.result_json !== null) { + if (latest.result_json !== resultJson) throw new LocalProtectionError("CONFLICT", "Concurrent authoritative observations disagree. The original durable receipt is retained; no operation was dispatched."); + return decodeResult(latest.result_json); } - return decodeResult(resultJson); + throw new LocalProtectionError("UNKNOWN", `State changed while reconciling ${id}. Retry with the same id; no second write was dispatched.`); } let currentFingerprint: string; @@ -420,23 +469,26 @@ export function protectLocal( copyData(options.payload(...callArgs)) as JsonObject, ); } catch (cause) { - db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED' AND owner=?").run(id, owner); - throw new LocalProtectionError("PAYLOAD_DRIFT", `Payload of ${id} changed before dispatch. No operation was dispatched.`, { cause }); + await store.markUnknown(id, fingerprint, owner); + throw new LocalProtectionError("PAYLOAD_DRIFT", `Payload of ${id} changed before dispatch. No operation was dispatched.`); } if (currentFingerprint !== fingerprint) { - db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED' AND owner=?").run(id, owner); + await store.markUnknown(id, fingerprint, owner); throw new LocalProtectionError("PAYLOAD_DRIFT", `Payload of ${id} changed before dispatch. No operation was dispatched.`); } - if (sharedSession) { - try { - sharedSession.assertStateIdentity(); - } catch (cause) { - try { - db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED' AND owner=?").run(id, owner); - } catch { /* The session is already fail-closed. */ } - throw cause; + await store.assertDispatch(id, fingerprint, owner); + + // Shared admission crosses an asynchronous boundary. Recheck selectors + // synchronously afterward so that admission-time drift cannot be executed. + try { + if (options.id(...callArgs) !== id || + fingerprintConnectPayload(copyData(options.payload(...callArgs)) as JsonObject) !== fingerprint) { + throw new Error("Binding changed during authority admission."); } + } catch (cause) { + await store.markUnknown(id, fingerprint, owner); + throw new LocalProtectionError("PAYLOAD_DRIFT", `Binding of ${id} changed during authority admission. No operation was dispatched.`); } let result: T; @@ -444,39 +496,32 @@ export function protectLocal( result = await operation.apply(this, callArgs); } catch (cause) { try { - db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED' AND owner=?").run(id, owner); - } catch { sharedSession?.invalidate(); } - throw new LocalProtectionError("UNKNOWN", `ORIGINAL OUTCOME UNKNOWN. Operation ${id} threw after dispatch; its external outcome may be unknown. It may already have succeeded. Future unsafe redispatch is blocked. Next action: reconcile authoritative provider truth before retrying with the same identity and durable state.`, { cause }); + await store.markUnknown(id, fingerprint, owner); + } catch { /* Durable claim remains non-dispatchable. */ } + throw new LocalProtectionError("UNKNOWN", `ORIGINAL OUTCOME UNKNOWN. Operation ${id} threw after dispatch; its external outcome may be unknown. It may already have succeeded. Future unsafe redispatch is blocked. Next action: reconcile authoritative provider truth before retrying with the same identity and durable state.`); } let resultJson: string; try { resultJson = encodeResult(result); } catch (cause) { try { - db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED' AND owner=?").run(id, owner); - } catch { sharedSession?.invalidate(); } - throw new LocalProtectionError("UNREPLAYABLE_RESULT", `Operation ${id} returned a non-JSON-safe result. Outcome is UNKNOWN; return a JSON-safe receipt and reconcile before retrying.`, { cause }); + await store.markUnknown(id, fingerprint, owner); + } catch { /* Durable claim remains non-dispatchable. */ } + throw new LocalProtectionError("UNREPLAYABLE_RESULT", `Operation ${id} returned a non-JSON-safe result. Outcome is UNKNOWN; return a JSON-safe receipt and reconcile before retrying.`); } - if (sharedSession) { - try { - sharedSession.assertStateIdentity(); - } catch (cause) { - try { - db.prepare("UPDATE local_operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED' AND owner=?").run(id, owner); - } catch { /* The session is already fail-closed. */ } - throw new LocalProtectionError("UNKNOWN", `Operation ${id} completed but its durable state path changed before confirmation. The external outcome may be unknown; do not retry until state is restored.`, { cause }); - } + let confirmed: boolean; + try { + confirmed = await store.confirm(id, fingerprint, owner, resultJson); + } catch (cause) { + throw new LocalProtectionError("UNKNOWN", `Operation ${id} completed but durable confirmation failed. Restore authority and reconcile provider truth; never redispatch.`, safeAuthorityFailure(cause)); } - - const update = db.prepare("UPDATE local_operations SET state='CONFIRMED',result_json=?,owner=NULL,lease_until=NULL WHERE id=? AND state='CLAIMED' AND owner=?") - .run(resultJson, id, owner); - if (update.changes !== 1) { + if (!confirmed) { throw new LocalProtectionError("EXECUTION_RIGHT_LOST", `Operation ${id} completed after its claim changed. Reconcile provider truth before retrying.`); } return decodeResult(resultJson); } finally { - if (closeAfterCall) db.close(); + store.close(); } }; } diff --git a/sdk/typescript/src/postgres-authority.ts b/sdk/typescript/src/postgres-authority.ts new file mode 100644 index 00000000..66bb668e --- /dev/null +++ b/sdk/typescript/src/postgres-authority.ts @@ -0,0 +1,277 @@ +import { LocalProtectionError, snapshotLocalData } from "./local.js"; +import type { ExecutionAuthority, ExecutionRow, ExecutionStore } from "./execution-store.js"; +import { witnessUsesPool } from "./postgres-continuity-witness.js"; +import { createHash, randomUUID } from "node:crypto"; +import { HOST_INTENT_AUTHORITY_CONTRACT, hostIntentEffectFingerprint, type HostIntentAuthority, type HostIntentBinding, type HostIntentRequest } from "./host-intent.js"; + +/** Compatible with a host-owned node-postgres Pool; credentials never enter Once. */ +export interface AuthoritySqlClient { + query(text: string, values?: unknown[]): Promise<{ rows: any[]; rowCount: number | null }>; + release(destroy?: boolean): void; +} +export interface AuthoritySqlPool { connect(): Promise } + +export interface AuthorityCheckpoint { + readonly authorityId: string; + readonly generation: string; + readonly epoch: string; + readonly revision: string; + /** Required by the host-intent v2 protocol; absent in revision-only v1. */ + readonly stateDigest?: string; +} + +export interface ContinuityWitness { + /** + * Linearizable, durable CAS in a separate failure/restore domain. Return true + * only after persisting next, false on mismatch (including missing state). + * Never create missing state, accept stale reads, or retry an ambiguous CAS. + * Host must provision and authenticate this authority out of band. + */ + compareAndAdvance(expected: AuthorityCheckpoint, next: AuthorityCheckpoint): Promise; +} + +export interface PostgresAuthorityOptions { + pool: AuthoritySqlPool; + witness: ContinuityWitness; + /** Fixed trusted host namespace, shared by every worker for the same domain. */ + authorityId: string; + expectedGeneration: string; + expectedEpoch: string; + /** Bounded acknowledgement deadline; ambiguity blocks, never retries CAS. */ + witnessTimeoutMs?: number; +} + +const decimal = (value: unknown): value is string => typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value); +const fail = (code: string, message: string): never => { throw new LocalProtectionError(code, message); }; + +/** + * Draft shared storage adapter for the EXISTING protectLocal execution kernel. + * No DDL, bootstrap, takeover, dispatch, provider lookup, or local fallback. + * Each short SQL transaction locks the authority row and advances the external + * witness BEFORE committing. A failed/ambiguous commit sacrifices availability: + * the witness stays ahead and all subsequent access fails closed. + */ +export function createPostgresExecutionAuthority(options: PostgresAuthorityOptions): ExecutionAuthority { + // Retained PR303 revision-only v1 contract. It does NOT qualify for the + // state-bound host-intent profile; its current-metadata partial restore limit + // is retained as a RED control. No implicit upgrade is performed. + return buildPostgresAuthority(options); +} + +/** Runtime lookup authority: expose only this capability to the execution host. + * Its SQL role needs SELECT on host_intents, never INSERT/UPDATE/DELETE. + */ +export function createPostgresIntentAuthority(options: PostgresAuthorityOptions): HostIntentAuthority { + const authority = buildPostgresAuthority(options, true); + return Object.freeze({ contract: HOST_INTENT_AUTHORITY_CONTRACT, open: authority.open, resolveIntent: authority.resolveIntent }); +} + +/** Explicit trusted host admission. Use a separately privileged admission pool. + * Never expose this function/credential or trusted keys through model/tool input. + * Existing key + changed effect is CONFLICT; no hash-based intent inference. + */ +export async function admitPostgresIntent(options: PostgresAuthorityOptions, request: HostIntentRequest): Promise { + return buildPostgresAuthority(options, true).admitIntent(request); +} + +function buildPostgresAuthority(options: PostgresAuthorityOptions, bindState = false) { + if (!options || typeof options.pool?.connect !== "function" || + typeof options.witness?.compareAndAdvance !== "function" || + typeof options.authorityId !== "string" || !options.authorityId.trim() || + typeof options.expectedGeneration !== "string" || !options.expectedGeneration.trim() || + !decimal(options.expectedEpoch)) { + return fail("INVALID_CONFIGURATION", "Shared authority requires a fixed pool, external continuity witness, authority identity, generation and decimal epoch."); + } + const { pool, witness, authorityId, expectedGeneration, expectedEpoch } = options; + if (witnessUsesPool(witness, pool)) fail("INVALID_CONFIGURATION", "Execution and continuity authorities must not share a pool or restore domain."); + const witnessTimeoutMs = options.witnessTimeoutMs ?? 5000; + if (!Number.isSafeInteger(witnessTimeoutMs) || witnessTimeoutMs <= 0 || witnessTimeoutMs > 60000) { + fail("INVALID_CONFIGURATION", "Witness acknowledgement deadline must be between 1 and 60000 milliseconds."); + } + const compareAndAdvance = witness.compareAndAdvance.bind(witness); + const connect = pool.connect.bind(pool); + const stateDigest = async (client: AuthoritySqlClient): Promise => { + // Full namespace verification is deliberate. A current revision copied onto + // empty/partial history must not pass. Normal writers hold the metadata lock. + const operations = await client.query("SELECT id,fingerprint,state,result_json,owner,lease_until::text FROM once_execution.operations WHERE authority_id=$1 ORDER BY id COLLATE \"C\"", [authorityId]); + const intents = await client.query("SELECT scope,intent_key,operation_id,effect_fingerprint FROM once_execution.host_intents WHERE authority_id=$1 ORDER BY scope COLLATE \"C\",intent_key COLLATE \"C\"", [authorityId]); + const value = { contract: "once-authority-state-v2", operations: operations.rows.map(r => [r.id,r.fingerprint,r.state,r.result_json,r.owner,r.lease_until]), + intents: intents.rows.map(r => [r.scope,r.intent_key,r.operation_id,r.effect_fingerprint]) }; + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); + }; + + async function transaction(action: (client: AuthoritySqlClient, now: number) => Promise, beforeCommit?: (now: number) => void): Promise { + let client: AuthoritySqlClient | undefined; + let committed = false; + try { + client = await connect(); + await client.query("BEGIN ISOLATION LEVEL READ COMMITTED"); + // Require durable acknowledgement even if the host's session default differs. + await client.query("SET LOCAL synchronous_commit = on"); + await client.query("SET LOCAL lock_timeout = '5s'"); + await client.query("SET LOCAL statement_timeout = '10s'"); + const durability = await client.query("SELECT current_setting('fsync') AS fsync,current_setting('full_page_writes') AS full_page_writes"); + if (durability.rows[0]?.fsync !== "on" || durability.rows[0]?.full_page_writes !== "on") fail("STATE_UNAVAILABLE", "Execution authority requires durable PostgreSQL writes."); + const { rows } = await client.query( + `SELECT schema_version,generation,epoch::text,revision::text${bindState ? ",state_digest" : ""} FROM once_execution.authorities WHERE authority_id=$1 FOR UPDATE`, [authorityId]); + const meta = rows[0]; + if (!meta) fail("AUTHORITY_MISSING", "Expected execution authority is missing. Restore the original authority; it will not be recreated."); + if (meta.schema_version !== (bindState ? 2 : 1) || meta.generation !== expectedGeneration || meta.epoch !== expectedEpoch || !decimal(meta.revision)) { + fail("STALE_AUTHORITY", "Execution authority schema, generation or epoch does not match host admission."); + } + // Evaluate leases using database time after acquiring the lock, never worker time. + const clock = await client.query("SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::text AS now"); + const now = Number(clock.rows[0]?.now); + if (!Number.isSafeInteger(now)) fail("STATE_UNAVAILABLE", "Authority clock is invalid."); + if (BigInt(meta.revision) >= 9223372036854775807n) fail("STATE_UNAVAILABLE", "Authority revision is exhausted; operator review is required."); + if (bindState && (typeof meta.state_digest !== "string" || !/^[a-f0-9]{64}$/.test(meta.state_digest) || await stateDigest(client) !== meta.state_digest)) { + fail("CONTINUITY_LOST", "Execution contents do not match their independently committed state. No dispatch is authorized."); + } + const result = await action(client, now); + const expected = Object.freeze({ authorityId, generation: expectedGeneration, epoch: expectedEpoch, revision: meta.revision as string, + ...(bindState ? { stateDigest: meta.state_digest as string } : {}) }); + const next = Object.freeze({ ...expected, revision: (BigInt(meta.revision) + 1n).toString(), + ...(bindState ? { stateDigest: await stateDigest(client) } : {}) }); + let accepted: boolean; + let deadline: ReturnType | undefined; + try { + accepted = await Promise.race([ + compareAndAdvance(expected, next), + new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error("Witness deadline")), witnessTimeoutMs); }), + ]); + } + catch { return fail("CONTINUITY_UNAVAILABLE", "Continuity acknowledgement is unavailable or ambiguous. No new dispatch is authorized; operator recovery is required."); } + finally { if (deadline) clearTimeout(deadline); } + if (accepted !== true) fail("CONTINUITY_LOST", "Execution history does not match the independent continuity authority. No fresh dispatch is authorized."); + if (beforeCommit) { + const currentClock = await client.query("SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::text AS now"); + const currentNow = Number(currentClock.rows[0]?.now); + if (!Number.isSafeInteger(currentNow)) fail("STATE_UNAVAILABLE", "Authority clock is invalid."); + beforeCommit(currentNow); + } + if (bindState) await client.query("UPDATE once_execution.authorities SET revision=$2::bigint,state_digest=$3 WHERE authority_id=$1", [authorityId, next.revision, next.stateDigest]); + else await client.query("UPDATE once_execution.authorities SET revision=$2::bigint WHERE authority_id=$1", [authorityId, next.revision]); + await client.query("COMMIT"); + committed = true; + return result; + } catch (error) { + if (client) { try { await client.query("ROLLBACK"); } catch { /* Destroy below. */ } } + if (error instanceof LocalProtectionError) throw error; + // Driver errors may contain DSNs, passwords or query values. Do not attach them. + return fail("STATE_UNAVAILABLE", "Shared authority access or commit failed. No fresh dispatch is authorized; verify continuity before recovery."); + } finally { + try { client?.release(!committed); } + catch { fail("STATE_UNAVAILABLE", "Shared authority connection cleanup failed; no fresh dispatch is authorized."); } + } + } + + async function read(client: AuthoritySqlClient, id: string): Promise { + const result = await client.query("SELECT fingerprint,state,result_json,owner,lease_until::text FROM once_execution.operations WHERE authority_id=$1 AND id=$2", [authorityId, id]); + const row = result.rows[0]; + if (!row) return undefined; + const lease = row.lease_until === null ? null : Number(row.lease_until); + if (typeof row.fingerprint !== "string" || !["CLAIMED", "UNKNOWN", "CONFIRMED"].includes(row.state) || + (row.state === "CLAIMED" && (typeof row.owner !== "string" || !row.owner || !Number.isSafeInteger(lease))) || + (row.state !== "CLAIMED" && (row.owner !== null || lease !== null)) || + (row.state === "CONFIRMED" ? typeof row.result_json !== "string" : row.result_json !== null)) { + fail("STATE_UNAVAILABLE", "Shared execution record is malformed; no dispatch is authorized."); + } + return { ...row, lease_until: lease }; + } + + const store: ExecutionStore = { + async reserve(id, fingerprint, owner, leaseMs) { + return transaction(async (client, now) => { + const row = await read(client, id); + if (row) { + if (row.fingerprint === fingerprint && row.state === "CLAIMED" && row.lease_until! <= now) { + await client.query("UPDATE once_execution.operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE authority_id=$1 AND id=$2", [authorityId, id]); + return { dispatch: false, row: { ...row, state: "UNKNOWN", owner: null, lease_until: null } as ExecutionRow }; + } + return { dispatch: false, row }; + } + if (!Number.isSafeInteger(now + leaseMs)) fail("INVALID_LEASE", "Lease exceeds the safe authority clock range."); + await client.query("INSERT INTO once_execution.operations(authority_id,id,fingerprint,state,owner,lease_until) VALUES($1,$2,$3,'CLAIMED',$4,$5)", [authorityId, id, fingerprint, owner, now + leaseMs]); + return { dispatch: true }; + }); + }, + async assertDispatch(id, fingerprint, owner) { + let leaseUntil = 0, remainingMs = 0, checkedAt = 0; + await transaction(async (client, now) => { + const row = await read(client, id); + if (row?.fingerprint !== fingerprint || row.state !== "CLAIMED" || row.owner !== owner || row.lease_until! <= now) { + fail("EXECUTION_RIGHT_LOST", "Execution fence is stale or expired. No fresh dispatch is authorized."); + } + leaseUntil = row!.lease_until!; + remainingMs = leaseUntil - now; + checkedAt = performance.now(); + }, now => { + if (leaseUntil <= now) fail("EXECUTION_RIGHT_LOST", "Execution lease expired during witness admission. No dispatch is authorized."); + }); + if (performance.now() - checkedAt >= remainingMs) { + fail("EXECUTION_RIGHT_LOST", "Execution lease expired before admission returned. No dispatch is authorized."); + } + }, + async markUnknown(id, fingerprint, owner) { + await transaction(async client => { + await client.query("UPDATE once_execution.operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE authority_id=$1 AND id=$2 AND fingerprint=$3 AND state='CLAIMED' AND owner=$4", [authorityId, id, fingerprint, owner]); + }); + }, + async confirm(id, fingerprint, owner, result) { + return transaction(async (client, now) => { + const update = await client.query("UPDATE once_execution.operations SET state='CONFIRMED',result_json=$5,owner=NULL,lease_until=NULL WHERE authority_id=$1 AND id=$2 AND fingerprint=$3 AND state='CLAIMED' AND owner=$4 AND lease_until>$6", [authorityId, id, fingerprint, owner, result, now]); + return update.rowCount === 1; + }); + }, + async reconcile(id, fingerprint, result) { + return transaction(async client => { + await client.query("UPDATE once_execution.operations SET state='CONFIRMED',result_json=$4 WHERE authority_id=$1 AND id=$2 AND fingerprint=$3 AND state='UNKNOWN'", [authorityId, id, fingerprint, result]); + return read(client, id); + }); + }, + close() { /* Pool lifecycle is host-owned; no request shuts down other workers. */ }, + }; + Object.freeze(store); + const key = (scope: string, intentKey: string) => { + if (typeof scope !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511}$/.test(scope) || + typeof intentKey !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,511}$/.test(intentKey)) { + fail("INTENT_REQUIRED", "Trusted host scope and durable intent key are required."); + } + }; + const readIntent = async (client: AuthoritySqlClient, scope: string, intentKey: string): Promise => { + const { rows } = await client.query("SELECT operation_id,effect_fingerprint FROM once_execution.host_intents WHERE authority_id=$1 AND scope=$2 AND intent_key=$3", [authorityId, scope, intentKey]); + if (!rows[0]) return undefined; + const { operation_id: operationId, effect_fingerprint: effectFingerprint } = rows[0]; + if (typeof operationId !== "string" || !/^intent:[a-f0-9-]{36}$/.test(operationId) || + typeof effectFingerprint !== "string" || !/^once-connect-payload-v1:[a-f0-9]{64}$/.test(effectFingerprint)) fail("STATE_UNAVAILABLE", "Trusted intent record is malformed."); + return Object.freeze({ scope, intentKey, operationId, effectFingerprint }); + }; + return Object.freeze({ + async open() { return store; }, + async resolveIntent(scope: string, intentKey: string) { + key(scope, intentKey); + return transaction(async client => { + const binding = await readIntent(client, scope, intentKey); + if (!binding) return fail("INTENT_REQUIRED", "Trusted intent is missing. Runtime recovery cannot create or replace it."); + return binding; + }); + }, + async admitIntent(request: HostIntentRequest) { + request = snapshotLocalData(request); + if (!request || typeof request !== "object" || Array.isArray(request) || Object.keys(request).sort().join(",") !== "effect,intentKey,scope") fail("INTENT_REQUIRED", "Explicit host scope, intent key and complete effect are required."); + key(request.scope, request.intentKey); + const fingerprint = hostIntentEffectFingerprint(request.effect); + const { scope, intentKey } = request; + return transaction(async client => { + const existing = await readIntent(client, scope, intentKey); + if (existing) { + if (existing.effectFingerprint !== fingerprint) return fail("CONFLICT", "Trusted intent already binds a different complete effect."); + return existing; + } + const binding = Object.freeze({ scope, intentKey, operationId: `intent:${randomUUID()}`, effectFingerprint: fingerprint }); + await client.query("INSERT INTO once_execution.host_intents(authority_id,scope,intent_key,operation_id,effect_fingerprint) VALUES($1,$2,$3,$4,$5)", [authorityId, scope, intentKey, binding.operationId, fingerprint]); + return binding; + }); + }, + }); +} diff --git a/sdk/typescript/src/postgres-continuity-witness.ts b/sdk/typescript/src/postgres-continuity-witness.ts new file mode 100644 index 00000000..b35d0568 --- /dev/null +++ b/sdk/typescript/src/postgres-continuity-witness.ts @@ -0,0 +1,88 @@ +import { LocalProtectionError, snapshotLocalData } from "./local.js"; +import type { AuthorityCheckpoint, AuthoritySqlClient, AuthoritySqlPool, ContinuityWitness } from "./postgres-authority.js"; + +const witnessPools = new WeakMap(); +export function witnessUsesPool(witness: ContinuityWitness, pool: AuthoritySqlPool): boolean { + return witnessPools.get(witness) === pool; +} + +export interface PostgresContinuityWitnessOptions { + /** A separately authenticated, verified-TLS host pool in another restore domain. */ + pool: AuthoritySqlPool; + /** Trusted operator-pinned witness identity, provisioned out of band. */ + expectedWitnessId: string; + /** v2 binds the actual execution/intent contents, not just the revision. */ + expectedSchemaVersion?: 1 | 2; +} + +const deny = (code: string): never => { throw new LocalProtectionError(code, "Independent continuity authority rejected the request; no execution permission is granted."); }; +function checkpoint(value: AuthorityCheckpoint): AuthorityCheckpoint { + let data: AuthorityCheckpoint; + try { data = snapshotLocalData(value); } catch { return deny("INVALID_WITNESS_REQUEST"); } + const keys = data && Object.keys(data).sort().join(","); + if (!data || !["authorityId,epoch,generation,revision", "authorityId,epoch,generation,revision,stateDigest"].includes(keys as string) || + typeof data.authorityId !== "string" || !data.authorityId.trim() || + typeof data.generation !== "string" || !data.generation.trim() || + ![data.epoch, data.revision].every(x => typeof x === "string" && /^(0|[1-9][0-9]*)$/.test(x)) || + BigInt(data.epoch) > 9223372036854775807n || BigInt(data.revision) > 9223372036854775807n || + (data.stateDigest !== undefined && (typeof data.stateDigest !== "string" || !/^[a-f0-9]{64}$/.test(data.stateDigest)))) { + deny("INVALID_WITNESS_REQUEST"); + } + return data; +} + +/** + * Durable witness implementation, never an execution dispatcher. Transport + * authentication, TLS verification, connection deadlines, independent placement + * and restore policy belong to the host-owned pool. No credentials/URLs/DDL, + * retries, implicit provisioning, rewind or repair are accepted by this API. + */ +export function createPostgresContinuityWitness(options: PostgresContinuityWitnessOptions): ContinuityWitness { + if (typeof options?.pool?.connect !== "function" || typeof options.expectedWitnessId !== "string" || !options.expectedWitnessId.trim()) { + return deny("INVALID_CONFIGURATION"); + } + const connect = options.pool.connect.bind(options.pool); + const expectedWitnessId = options.expectedWitnessId; + const expectedSchemaVersion = options.expectedSchemaVersion ?? 1; + if (![1, 2].includes(expectedSchemaVersion)) return deny("INVALID_CONFIGURATION"); + const witness: ContinuityWitness = Object.freeze({ + async compareAndAdvance(expectedInput: AuthorityCheckpoint, nextInput: AuthorityCheckpoint): Promise { + const expected = checkpoint(expectedInput), next = checkpoint(nextInput); + if ((expectedSchemaVersion === 2) !== (expected.stateDigest !== undefined && next.stateDigest !== undefined) || + (expectedSchemaVersion === 1 && (expected.stateDigest !== undefined || next.stateDigest !== undefined))) return deny("INVALID_WITNESS_REQUEST"); + if (next.authorityId !== expected.authorityId || next.generation !== expected.generation || + next.epoch !== expected.epoch || BigInt(next.revision) !== BigInt(expected.revision) + 1n) { + return deny("INVALID_WITNESS_REQUEST"); + } + let client: AuthoritySqlClient | undefined, committed = false; + try { + client = await connect(); + await client.query("BEGIN ISOLATION LEVEL READ COMMITTED"); + await client.query("SET LOCAL synchronous_commit = on"); + await client.query("SET LOCAL lock_timeout = '5s'"); + await client.query("SET LOCAL statement_timeout = '10s'"); + const durability = await client.query("SELECT current_setting('fsync') AS fsync,current_setting('full_page_writes') AS full_page_writes"); + if (durability.rows[0]?.fsync !== "on" || durability.rows[0]?.full_page_writes !== "on") deny("WITNESS_DURABILITY_REQUIRED"); + const metadata = await client.query("SELECT witness_id,schema_version FROM once_continuity.metadata WHERE singleton=true FOR SHARE"); + if (!metadata.rows[0]) deny("WITNESS_MISSING"); + if (metadata.rows[0].witness_id !== expectedWitnessId || metadata.rows[0].schema_version !== expectedSchemaVersion) deny("STALE_WITNESS"); + const updated = expectedSchemaVersion === 2 ? await client.query( + "UPDATE once_continuity.checkpoints SET revision=$5::bigint,state_digest=$7 WHERE authority_id=$1 AND generation=$2 AND epoch=$3::bigint AND revision=$4::bigint AND state_digest=$6 RETURNING revision::text", + [expected.authorityId, expected.generation, expected.epoch, expected.revision, next.revision, expected.stateDigest, next.stateDigest]) : await client.query( + "UPDATE once_continuity.checkpoints SET revision=$5::bigint WHERE authority_id=$1 AND generation=$2 AND epoch=$3::bigint AND revision=$4::bigint RETURNING revision::text", + [expected.authorityId, expected.generation, expected.epoch, expected.revision, next.revision]); + await client.query("COMMIT"); + committed = true; + return updated.rowCount === 1; + } catch (error) { + if (client) { try { await client.query("ROLLBACK"); } catch { /* Destroy below; never retry CAS. */ } } + if (error instanceof LocalProtectionError) throw error; + return deny("CONTINUITY_UNAVAILABLE"); + } finally { + try { client?.release(!committed); } catch { deny("CONTINUITY_UNAVAILABLE"); } + } + }, + }); + witnessPools.set(witness, options.pool); + return witness; +} diff --git a/sdk/typescript/src/tool-call.ts b/sdk/typescript/src/tool-call.ts index 48284c0f..31f5c0e4 100644 --- a/sdk/typescript/src/tool-call.ts +++ b/sdk/typescript/src/tool-call.ts @@ -1,3 +1,4 @@ +import type { ExecutionAuthority } from "./execution-store.js"; import { LocalProtectionError, protectLocal, snapshotLocalData } from "./local.js"; export interface ToolCallEffect = Record> { @@ -23,17 +24,19 @@ export interface ToolCallProtectionOptions, T> /** Ignored by identity and fingerprinting. Must not change the external effect. */ metadata?: unknown; statePath?: string; + /** Explicit host-owned shared authority; no local fallback. */ + authority?: ExecutionAuthority; leaseMs?: number; } -/** Protect a host-supplied callable using the existing local durable authority. */ +/** Protect a host-supplied callable using the existing kernel and explicit durable authority. */ export async function protectToolCall, T>( options: ToolCallProtectionOptions, ): Promise { if (typeof options?.operationId !== "string" || !options.operationId.trim()) { throw new LocalProtectionError("IDENTITY_REQUIRED", "protectToolCall requires an explicit stable operationId; no tool was dispatched."); } - const { operationId, execute, reconcile, statePath, leaseMs } = options; + const { operationId, execute, reconcile, statePath, leaseMs, authority } = options; if (typeof execute !== "function" || (reconcile !== undefined && typeof reconcile !== "function")) { throw new LocalProtectionError("INVALID_CONFIGURATION", "execute and optional reconcile must be callable; no tool was dispatched."); } @@ -51,6 +54,7 @@ export async function protectToolCall, T>( // A version tag prevents accidental receipt reuse across protection contracts. payload: () => ({ contract: "once-tool-call-v1", tool: effect.tool, args: effect.args }), statePath, + authority, leaseMs, reconcile: reconcile ? async () => { const observation = snapshotLocalData(await reconcile({ operationId, effect })); diff --git a/sdk/typescript/src/wrap-tool.ts b/sdk/typescript/src/wrap-tool.ts index c7db17c6..65f8c842 100644 --- a/sdk/typescript/src/wrap-tool.ts +++ b/sdk/typescript/src/wrap-tool.ts @@ -1,3 +1,4 @@ +import type { ExecutionAuthority } from "./execution-store.js"; import { LocalProtectionError, snapshotLocalData } from "./local.js"; import { protectToolCall, type ToolCallEffect, type ToolCallProtectionOptions } from "./tool-call.js"; @@ -8,6 +9,8 @@ export interface WrapToolOptions, Args ext effect: (input: Input) => ToolCallEffect; reconcile?: ToolCallProtectionOptions["reconcile"]; statePath?: string; + /** Explicit host-owned shared authority; no local fallback. */ + authority?: ExecutionAuthority; leaseMs?: number; } @@ -15,7 +18,8 @@ export interface WrapToolOptions, Args ext * Replace a host-owned unary callback. The callback receives frozen effect.args, * not the original input. Bind methods when needed; receiver/closure authority * must be fixed or explicitly represented in the effect. Internal callback - * retries are outside this boundary. Requires Node 24.15+ and shared local state. + * retries are outside this boundary. Local state requires Node 24.15+; an explicit + * shared authority uses the same kernel without opening SQLite. */ export function wrapTool, Result, Input extends Record = Args, Receiver = unknown>( callback: (this: Receiver, args: Args) => Promise, @@ -26,7 +30,7 @@ export function wrapTool, Result, Input ext (options.reconcile !== undefined && typeof options.reconcile !== "function")) { throw new LocalProtectionError("INVALID_CONFIGURATION", "wrapTool requires a callback and explicit operationId/effect selectors; no tool was dispatched."); } - const { operationId, effect, reconcile, statePath, leaseMs } = options; + const { operationId, effect, reconcile, statePath, leaseMs, authority } = options; return async function (this: Receiver, input: Input): Promise { const snapshot = snapshotLocalData(input); return protectToolCall({ @@ -35,6 +39,7 @@ export function wrapTool, Result, Input ext execute: ({ args }) => callback.call(this, args), reconcile, statePath, + authority, leaseMs, }); }; diff --git a/sdk/typescript/test/authority-canonical-security.test.mjs b/sdk/typescript/test/authority-canonical-security.test.mjs new file mode 100644 index 00000000..4c3d2086 --- /dev/null +++ b/sdk/typescript/test/authority-canonical-security.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtempSync, rmSync, appendFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { protectToolCall } from '../dist/index.js'; +import { canonicalizeConnectPayload, fingerprintConnectPayload } from '../dist/connect/binding.js'; + +test('hostile own JSON keys retain distinct canonical bytes, including nested arrays and integer keys', () => { + const a = JSON.parse('{"amount":1,"__proto__":{"target":"A"},"nested":[{"2":"b","10":"a","constructor":{"prototype":{"target":"A"}}}]}'); + const b = JSON.parse('{"amount":1,"__proto__":{"target":"B"},"nested":[{"2":"b","10":"a","constructor":{"prototype":{"target":"A"}}}]}'); + assert.equal(canonicalizeConnectPayload(a), '{"__proto__":{"target":"A"},"amount":1,"nested":[{"10":"a","2":"b","constructor":{"prototype":{"target":"A"}}}]}'); + assert.notEqual(canonicalizeConnectPayload(a), canonicalizeConnectPayload(b)); + assert.notEqual(fingerprintConnectPayload(a), fingerprintConnectPayload(b)); + assert.equal(Object.getPrototypeOf(a), Object.prototype); + assert.equal(Object.prototype.target, undefined); +}); + +for (const path of ['root', 'nested object', 'array element']) { + test(`durable replay preserves ${path} __proto__ effect and receipt; changed target conflicts`, async t => { + const dir = mkdtempSync(join(tmpdir(), 'once-canonical-security-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const statePath = join(dir, 'state.sqlite'), journal = join(dir, 'provider.jsonl'); + const args = target => { + const value = JSON.parse(`{"2":"b","10":"a","__proto__":{"target":"${target}"}}`); + return path === 'root' ? value : path === 'nested object' ? { nested: value } : { nested: [value] }; + }; + const run = target => protectToolCall({ + operationId: 'fixed-intent', effect: { tool: 'fixture.account-A.effect', args: args(target) }, statePath, + execute: async effect => { + appendFileSync(journal, JSON.stringify(effect.args) + '\n', { flush: true }); + const value = path === 'root' ? effect.args : path === 'nested object' ? effect.args.nested : effect.args.nested[0]; + assert(Object.isFrozen(value)); + assert(Object.hasOwn(value, '__proto__')); + assert.equal(Object.getPrototypeOf(value), Object.prototype); + return effect.args; + }, + }); + const first = await run('A'); + assert.deepEqual(first, args('A')); + assert.deepEqual(await run('A'), first); + await assert.rejects(run('B'), { code: 'CONFLICT' }); + assert.deepEqual(readFileSync(journal, 'utf8').trim().split('\n').map(JSON.parse), [args('A')]); + assert.equal(Object.prototype.target, undefined); + }); +} diff --git a/sdk/typescript/test/execution-store.test.mjs b/sdk/typescript/test/execution-store.test.mjs new file mode 100644 index 00000000..2c1e56f3 --- /dev/null +++ b/sdk/typescript/test/execution-store.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { protectLocal, createPostgresExecutionAuthority } from '../dist/index.js'; + +for (const drift of ['identity', 'effect']) { + test(`kernel rejects ${drift} drift during asynchronous authority admission`, async () => { + let id = 'intent-1', amount = 1, effects = 0, unknown = 0; + const authority = { async open() { return { + async reserve() { return { dispatch: true }; }, + async assertDispatch() { await Promise.resolve(); if (drift === 'identity') id = 'intent-2'; else amount = 2; }, + async markUnknown() { unknown++; }, + async confirm() { throw new Error('must not confirm'); }, + close() {}, + }; } }; + const run = protectLocal(async () => { effects++; }, { id: () => id, payload: () => ({ amount }), authority }); + await assert.rejects(run(), { code: 'PAYLOAD_DRIFT' }); + assert.equal(effects, 0); assert.equal(unknown, 1); + }); +} + +test('authority selection is captured and never silently switches to local state', async () => { + let opens = 0, effects = 0; + const options = { id: () => 'intent', payload: () => ({}), authority: { async open() { opens++; throw new Error('shared unavailable'); } } }; + const run = protectLocal(async () => { effects++; }, options); + options.authority = undefined; + await assert.rejects(run(), /shared unavailable/); + assert.equal(opens, 1); assert.equal(effects, 0); +}); + +test('missing witness and invalid admission cannot construct shared authority', () => { + for (const expectedEpoch of [undefined, 1, '', '-1', '01']) { + assert.throws(() => createPostgresExecutionAuthority({ + pool: { connect() {} }, witness: { compareAndAdvance() {} }, authorityId: 'domain', expectedGeneration: 'generation', expectedEpoch, + }), { code: 'INVALID_CONFIGURATION' }); + } + assert.throws(() => createPostgresExecutionAuthority({ pool: { connect() {} }, authorityId: 'domain', expectedGeneration: 'generation', expectedEpoch: '1' }), { code: 'INVALID_CONFIGURATION' }); +}); + +test('later mutation of the supplied authority opener cannot replace execution authority', async () => { + let effects = 0; + const authority = { async open() { throw Error('original authority unavailable'); } }; + const run = protectLocal(async () => { effects++; }, { id: () => 'stable-intent', payload: () => ({}), authority }); + authority.open = async () => { throw Error('substituted opener'); }; + await assert.rejects(run(), /original authority unavailable/); + assert.equal(effects, 0); +}); + +import { inspect } from 'node:util'; +test('callback errors never expose credential-bearing causes', async () => { + const token = 'synthetic-secret-do-not-expose'; + let saved; + const authority = { async open() { return { + async reserve(id, fingerprint) { return saved ? { dispatch: false, row: saved } : { dispatch: true }; }, + async assertDispatch() {}, + async markUnknown(id, fingerprint) { saved = { id, fingerprint, state: 'UNKNOWN' }; }, + async confirm() { throw new Error('unexpected'); }, close() {}, + }; } }; + const failure = new Error(`Bearer ${token}`); + failure.request = { headers: { authorization: token } }; + const run = protectLocal(async () => { throw failure; }, { + id: () => 'redaction', payload: () => ({ amount: 1 }), authority, + reconcile: async () => { throw failure; }, + }); + for (let attempt = 0; attempt < 2; attempt++) { + await assert.rejects(run(), error => { + assert.equal(inspect(error, { depth: 20 }).includes(token), false); + assert.equal(error.cause, undefined); + return true; + }); + } +}); diff --git a/sdk/typescript/test/local.test.mjs b/sdk/typescript/test/local.test.mjs index b964a7ab..45bb5498 100644 --- a/sdk/typescript/test/local.test.mjs +++ b/sdk/typescript/test/local.test.mjs @@ -184,7 +184,7 @@ test("unreplayable result leaves one effect and blocks retry", async t => { assert.equal(count(f.effectsPath), 1); }); -test("concurrent reconciliation returns the durable winner's receipt", async t => { +test("contradictory concurrent reconciliation conflicts and preserves the durable winner", async t => { const f = fixture(t); const input = { id: "A", amount: 100 }; const first = wrapped(f, async () => { effect(f.effectsPath, 100); throw new Error("lost"); }); @@ -202,7 +202,8 @@ test("concurrent reconciliation returns the durable winner's receipt", async t = await entered; assert.deepEqual(await fast(input), { receipt: "winner" }); release(); - assert.deepEqual(await pending, { receipt: "winner" }); + await assert.rejects(pending, { code: "CONFLICT" }); + assert.deepEqual(await fast(input), { receipt: "winner" }); assert.equal(count(f.effectsPath), 1); }); diff --git a/sdk/typescript/test/postgres-authority.test.mjs b/sdk/typescript/test/postgres-authority.test.mjs new file mode 100644 index 00000000..50baecac --- /dev/null +++ b/sdk/typescript/test/postgres-authority.test.mjs @@ -0,0 +1,323 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; +import { appendFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import pg from 'pg'; +import { createPostgresExecutionAuthority, protectToolCall, protectLocal, wrapTool } from '../dist/index.js'; + +// This command deliberately FAILS without a database; no silently skipped proof. +const database = process.env.ONCE_TEST_POSTGRES; +if (!database) throw new Error('Set ONCE_TEST_POSTGRES to a disposable local PostgreSQL admin database. Tests create and drop one isolated test database.'); + +test('shared PostgreSQL authority adversarial integration', async t => { + const admin = new pg.Pool({ connectionString: database }); + const dbName = `once_test_${randomUUID().replaceAll('-', '')}`; + await admin.query(`CREATE DATABASE ${dbName}`); + const url = new URL(database); url.pathname = `/${dbName}`; + const pool = new pg.Pool({ connectionString: url.href, max: 24 }); + const dir = mkdtempSync(join(tmpdir(), 'once-shared-')); + const effects = join(dir, 'provider.jsonl'); + const witnessDb = new DatabaseSync(join(dir, 'independent-witness.sqlite')); + witnessDb.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE checkpoints(id TEXT PRIMARY KEY, checkpoint TEXT NOT NULL)'); + let unavailable = false; + let loseWitnessAck = false; + const server = createServer(async (request, response) => { + try { + let body = ''; for await (const chunk of request) body += chunk; + const data = body ? JSON.parse(body) : {}; + if (request.url === '/cas') { + if (unavailable) { response.writeHead(503).end(); return; } + const row = witnessDb.prepare('SELECT checkpoint FROM checkpoints WHERE id=?').get(data.expected.authorityId); + const accepted = !!row && row.checkpoint === JSON.stringify(data.expected); + if (accepted) witnessDb.prepare('UPDATE checkpoints SET checkpoint=? WHERE id=?').run(JSON.stringify(data.next), data.expected.authorityId); + if (loseWitnessAck) { request.socket.destroy(); return; } + response.end(JSON.stringify({ accepted })); + } else if (request.url === '/effect') { + const receipt = { receipt: randomUUID(), id: data.id, amount: data.amount }; + appendFileSync(effects, JSON.stringify(receipt) + '\n', { flush: true }); + response.end(JSON.stringify(receipt)); + } else { response.writeHead(404).end(); } + } catch { response.writeHead(500).end(); } + }); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + const fixture = `http://127.0.0.1:${server.address().port}`; + t.after(async () => { + try { + const evidenceDir = process.env.ONCE_TEST_EVIDENCE_DIR; + if (evidenceDir) { + mkdirSync(evidenceDir, { recursive: true }); + const provider = readFileSync(effects, 'utf8'); + writeFileSync(join(evidenceDir, 'provider-journal.jsonl'), provider); + writeFileSync(join(evidenceDir, 'witness-checkpoints.json'), JSON.stringify(witnessDb.prepare('SELECT checkpoint FROM checkpoints ORDER BY id').all().map(row => JSON.parse(row.checkpoint)), null, 2)); + writeFileSync(join(evidenceDir, 'authority-snapshot.json'), JSON.stringify({ + scope: 'Local controlled fixture; deliberate fault injection included. Not production or real-provider evidence.', + authorities: (await pool.query('SELECT * FROM once_execution.authorities ORDER BY authority_id')).rows, + operations: (await pool.query('SELECT * FROM once_execution.operations ORDER BY authority_id,id')).rows, + }, null, 2)); + } + } finally { + server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); + witnessDb.close(); await pool.end(); + await admin.query(`DROP DATABASE ${dbName}`); await admin.end(); + rmSync(dir, { recursive: true, force: true }); + } + }); + await pool.query(readFileSync(new URL('../sql/execution-authority-v1.sql', import.meta.url), 'utf8')); + const journal = id => { try { return readFileSync(effects, 'utf8').trim().split('\n').map(JSON.parse).filter(x => x.id === id); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } }; + const witness = { async compareAndAdvance(expected, next) { + const response = await fetch(`${fixture}/cas`, { method: 'POST', body: JSON.stringify({ expected, next }) }); + if (!response.ok) throw new Error('unavailable'); + return (await response.json()).accepted; + } }; + async function provision() { + const authorityId = randomUUID(); + await pool.query('INSERT INTO once_execution.authorities VALUES($1,1,$2,1,0)', [authorityId, 'generation-1']); + witnessDb.prepare('INSERT INTO checkpoints VALUES(?,?)').run(authorityId, JSON.stringify({ authorityId, generation: 'generation-1', epoch: '1', revision: '0' })); + return { authorityId, expectedGeneration: 'generation-1', expectedEpoch: '1', pool, witness }; + } + const authorityFor = config => createPostgresExecutionAuthority(config); + function run(config, id, extra = {}) { + return protectToolCall({ + operationId: id, effect: { tool: 'fixture.account-A.refund', args: { id, amount: 100 } }, authority: authorityFor(config), + execute: async ({ args }) => (await fetch(`${fixture}/effect`, { method: 'POST', body: JSON.stringify(args) })).json(), + ...extra, + }); + } + function child(config, id, extra = {}) { + return new Promise((resolve, reject) => { + const process = spawn(globalThis.process.execPath, [fileURLToPath(new URL('./shared-authority-worker.mjs', import.meta.url))], { + windowsHide: true, env: { ...globalThis.process.env, ONCE_TEST_DATABASE: url.href, ONCE_TEST_WORKER: JSON.stringify({ fixture, authorityId: config.authorityId, id, ...extra }) }, + }); + let stdout = '', stderr = ''; + process.stdout.on('data', chunk => stdout += chunk); process.stderr.on('data', chunk => stderr += chunk); + process.on('error', reject); + process.on('exit', code => { + if (((extra.crash && stdout === 'CRASH_AFTER_PROVIDER_COMMIT') || (extra.crashBefore && stdout === 'CRASH_BEFORE_PROVIDER_DISPATCH')) && code !== 0) resolve({ crash: true }); + else if (code !== 0) reject(new Error(`Test child failed: ${stderr}`)); + else { try { resolve(JSON.parse(stdout)); } catch (error) { reject(error); } } + }); + }); + } + + await t.test('eight separate workers have one winner; restart replays and changed effects conflict', async () => { + const c = await provision(), id = randomUUID(); + const results = await Promise.all(Array.from({ length: 8 }, () => child(c, id))); + assert(results.some(x => x.result)); + assert(results.every(x => x.result || x.code === 'IN_FLIGHT')); + assert.equal(journal(id).length, 1); + assert.deepEqual((await child(c, id)).result, journal(id)[0]); + assert.equal((await child(c, id, { amount: 200 })).code, 'CONFLICT'); + await assert.rejects(run(c, id, { effect: { tool: 'fixture.account-B.refund', args: { id, amount: 100 } } }), { code: 'CONFLICT' }); + assert.equal(journal(id).length, 1); + }); + + await t.test('process death after provider commit blocks fresh workers until authoritative reconciliation', async () => { + const c = await provision(), id = randomUUID(); + assert.deepEqual(await child(c, id, { crash: true }), { crash: true }); + await pool.query('UPDATE once_execution.operations SET lease_until=0 WHERE authority_id=$1', [c.authorityId]); + assert.equal((await child(c, id)).code, 'UNKNOWN'); + for (const status of ['NOT_FOUND', 'UNKNOWN']) { + await assert.rejects(run(c, id, { reconcile: () => ({ status }) }), { code: 'UNKNOWN' }); + } + await assert.rejects(run(c, id, { reconcile: () => { throw new Error('lookup outage'); } }), { code: 'UNKNOWN' }); + assert.equal(journal(id).length, 1); + const truth = journal(id)[0]; // Independent fixture-provider durable journal is authoritative here only. + assert.deepEqual(await run(c, id, { reconcile: () => ({ status: 'CONFIRMED', result: truth }) }), truth); + assert.deepEqual((await child(c, id)).result, truth); + assert.equal(journal(id).length, 1); + }); + + await t.test('lost acknowledgement and invalid receipts never permit redispatch', async () => { + const c = await provision(), id = randomUUID(); + await assert.rejects(run(c, id, { execute: async ({ args }) => { + await fetch(`${fixture}/effect`, { method: 'POST', body: JSON.stringify(args) }); throw new Error('lost ack'); + } }), { code: 'UNKNOWN' }); + await assert.rejects(run(c, id), { code: 'UNKNOWN' }); + await assert.rejects(run(c, id, { reconcile: () => ({ status: 'CONFIRMED' }) }), { code: 'UNKNOWN' }); + assert.equal(journal(id).length, 1); + }); + + await t.test('crash after reservation but before dispatch remains blocked after restart', async () => { + const c = await provision(), id = randomUUID(); + assert.deepEqual(await child(c, id, { crashBefore: true }), { crash: true }); + await pool.query('UPDATE once_execution.operations SET lease_until=0 WHERE authority_id=$1', [c.authorityId]); + assert.equal((await child(c, id)).code, 'UNKNOWN'); + assert.equal(journal(id).length, 0); + }); + + await t.test('stale worker finishing after expiry cannot confirm or trigger a new effect', async () => { + const c = await provision(), id = randomUUID(); + await assert.rejects(run(c, id, { execute: async ({ args }) => { + const receipt = await (await fetch(`${fixture}/effect`, { method: 'POST', body: JSON.stringify(args) })).json(); + await pool.query('UPDATE once_execution.operations SET lease_until=0 WHERE authority_id=$1', [c.authorityId]); + await assert.rejects(run(c, id), { code: 'UNKNOWN' }); + return receipt; + } }), { code: 'EXECUTION_RIGHT_LOST' }); + await assert.rejects(run(c, id), { code: 'UNKNOWN' }); + assert.equal(journal(id).length, 1); + }); + + await t.test('authority epoch drift before dispatch blocks callback; after dispatch cannot confirm', async () => { + const c = await provision(), store = await authorityFor(c).open(), id = randomUUID(); + const authority = { async open() { return { ...store, async reserve(...args) { + const result = await store.reserve(...args); + await pool.query('UPDATE once_execution.authorities SET epoch=2 WHERE authority_id=$1', [c.authorityId]); + return result; + } }; } }; + await assert.rejects(run(c, id, { authority }), { code: 'STALE_AUTHORITY' }); + assert.equal(journal(id).length, 0); + const c2 = await provision(), id2 = randomUUID(); + await assert.rejects(run(c2, id2, { execute: async ({ args }) => { + const receipt = await (await fetch(`${fixture}/effect`, { method: 'POST', body: JSON.stringify(args) })).json(); + await pool.query('UPDATE once_execution.authorities SET epoch=2 WHERE authority_id=$1', [c2.authorityId]); + return receipt; + } }), error => error.code === 'UNKNOWN' && error.cause.code === 'STALE_AUTHORITY'); + await assert.rejects(run(c2, id2), { code: 'STALE_AUTHORITY' }); + assert.equal(journal(id2).length, 1); + }); + + await t.test('concurrent reconciliation retains the first durable provider receipt', async () => { + const c = await provision(), id = randomUUID(); + await assert.rejects(run(c, id, { execute: async () => { throw new Error('uncertain'); } }), { code: 'UNKNOWN' }); + const recovered = await Promise.all(Array.from({ length: 8 }, () => run(c, id, { + reconcile: () => ({ status: 'CONFIRMED', result: { receipt: 'same-authoritative-result' } }), + }))); + assert(recovered.every(x => x.receipt === 'same-authoritative-result')); + assert.equal(journal(id).length, 0); + }); + + await t.test('expired reservation is not reassigned; stale owners cannot dispatch or confirm', async () => { + const c = await provision(), store = await authorityFor(c).open(), id = randomUUID(); + assert.equal((await store.reserve(id, 'fingerprint', 'worker-A', 30000)).dispatch, true); + await pool.query('UPDATE once_execution.operations SET lease_until=0 WHERE authority_id=$1', [c.authorityId]); + await assert.rejects(store.assertDispatch(id, 'fingerprint', 'worker-A'), { code: 'EXECUTION_RIGHT_LOST' }); + const retry = await store.reserve(id, 'fingerprint', 'worker-B', 30000); + assert.equal(retry.dispatch, false); assert.equal(retry.row.state, 'UNKNOWN'); + assert.equal(await store.confirm(id, 'fingerprint', 'worker-A', '{"hasValue":false}'), false); + assert.equal(journal(id).length, 0); + }); + + await t.test('fence loss between reservation and dispatch blocks the callback', async () => { + const c = await provision(), store = await authorityFor(c).open(), id = randomUUID(); + const authority = { async open() { return { ...store, async reserve(...args) { + const result = await store.reserve(...args); + await pool.query("UPDATE once_execution.operations SET state='UNKNOWN',owner=NULL,lease_until=NULL WHERE authority_id=$1", [c.authorityId]); + return result; + } }; } }; + await assert.rejects(run(c, id, { authority }), { code: 'EXECUTION_RIGHT_LOST' }); + assert.equal(journal(id).length, 0); + }); + + await t.test('older valid history is rejected by independent witness, even for a new identity', async () => { + const c = await provision(), id = randomUUID(); + await run(c, id); + // Simulate restoring the complete earlier empty authority history. + await pool.query('DELETE FROM once_execution.operations WHERE authority_id=$1', [c.authorityId]); + await pool.query('UPDATE once_execution.authorities SET revision=0 WHERE authority_id=$1', [c.authorityId]); + await assert.rejects(run(c, id), { code: 'CONTINUITY_LOST' }); + await assert.rejects(run(c, randomUUID()), { code: 'CONTINUITY_LOST' }); + assert.equal(journal(id).length, 1); + assert.equal((await pool.query('SELECT * FROM once_execution.operations WHERE authority_id=$1', [c.authorityId])).rowCount, 0); + }); + + await t.test('missing authority is not recreated and wrong schema/generation/epoch fail closed', async () => { + const c = await provision(), id = randomUUID(); + for (const change of [{ expectedGeneration: 'other' }, { expectedEpoch: '0' }]) { + await assert.rejects(run({ ...c, ...change }, id), { code: 'STALE_AUTHORITY' }); + } + await pool.query('DELETE FROM once_execution.authorities WHERE authority_id=$1', [c.authorityId]); + await assert.rejects(run(c, id), { code: 'AUTHORITY_MISSING' }); + assert.equal((await pool.query('SELECT * FROM once_execution.authorities WHERE authority_id=$1', [c.authorityId])).rowCount, 0); + assert.equal(journal(id).length, 0); + }); + + await t.test('witness outage blocks execution and replay; missing witness state is never bootstrapped', async () => { + const c = await provision(), id = randomUUID(); await run(c, id); + unavailable = true; + try { + await assert.rejects(run(c, id), { code: 'CONTINUITY_UNAVAILABLE' }); + await assert.rejects(run(c, randomUUID()), { code: 'CONTINUITY_UNAVAILABLE' }); + } finally { unavailable = false; } + witnessDb.prepare('DELETE FROM checkpoints WHERE id=?').run(c.authorityId); + await assert.rejects(run(c, id), { code: 'CONTINUITY_LOST' }); + assert.equal(journal(id).length, 1); + }); + + await t.test('lost witness acknowledgement leaves witness ahead and blocks all subsequent work', async () => { + const c = await provision(), id = randomUUID(); loseWitnessAck = true; + try { await assert.rejects(run(c, id), { code: 'CONTINUITY_UNAVAILABLE' }); } + finally { loseWitnessAck = false; } + await assert.rejects(run(c, id), { code: 'CONTINUITY_LOST' }); + assert.equal(journal(id).length, 0); + }); + + await t.test('database commit failure after witness advancement never dispatches or retries CAS', async () => { + const c = await provision(), id = randomUUID(); let commits = 0; + const broken = { async connect() { + const client = await pool.connect(); + return { release: destroy => client.release(destroy), async query(sql, args) { + if (sql === 'COMMIT') { commits++; throw new Error('synthetic commit failure'); } + return client.query(sql, args); + } }; + } }; + await assert.rejects(run({ ...c, pool: broken }, id), { code: 'STATE_UNAVAILABLE' }); + assert.equal(commits, 1); + await assert.rejects(run(c, id), { code: 'CONTINUITY_LOST' }); + assert.equal(journal(id).length, 0); + }); + + await t.test('confirmation COMMIT acknowledgement loss returns UNKNOWN then replays durable truth', async () => { + const c = await provision(), id = randomUUID(); let commits = 0; + const lostAck = { async connect() { + const client = await pool.connect(); + return { release: destroy => client.release(destroy), async query(sql, args) { + const result = await client.query(sql, args); + if (sql === 'COMMIT' && ++commits === 3) throw new Error('lost COMMIT acknowledgement'); + return result; + } }; + } }; + await assert.rejects(run({ ...c, pool: lostAck }, id), { code: 'UNKNOWN' }); + assert.deepEqual(await run(c, id), journal(id)[0]); assert.equal(journal(id).length, 1); + }); + + await t.test('missing operations table is not recreated and unsupported schema version is denied', async () => { + const c = await provision(), id = randomUUID(); + await pool.query('ALTER TABLE once_execution.operations RENAME TO unavailable_operations'); + try { await assert.rejects(run(c, id), { code: 'STATE_UNAVAILABLE' }); } + finally { await pool.query('ALTER TABLE once_execution.unavailable_operations RENAME TO operations'); } + await pool.query('ALTER TABLE once_execution.authorities DROP CONSTRAINT authorities_schema_version_check'); + await pool.query('UPDATE once_execution.authorities SET schema_version=2 WHERE authority_id=$1', [c.authorityId]); + await assert.rejects(run(c, id), { code: 'STALE_AUTHORITY' }); + await pool.query('UPDATE once_execution.authorities SET schema_version=1 WHERE authority_id=$1', [c.authorityId]); + await pool.query('ALTER TABLE once_execution.authorities ADD CONSTRAINT authorities_schema_version_check CHECK(schema_version=1)'); + assert.equal(journal(id).length, 0); + }); + + await t.test('state unavailable does not fallback and driver secrets are not exposed', async () => { + const c = await provision(), id = randomUUID(); + const unavailablePool = { async connect() { throw new Error('password=synthetic-secret'); } }; + await assert.rejects(run({ ...c, pool: unavailablePool }, id), error => { + assert.equal(error.code, 'STATE_UNAVAILABLE'); + assert(!JSON.stringify(error).includes('synthetic-secret')); assert.equal(error.cause, undefined); return true; + }); + assert.equal(journal(id).length, 0); + assert.throws(() => protectLocal(async () => {}, { id: () => id, payload: () => ({}), statePath: 'unused', authority: authorityFor(c) }), { code: 'INVALID_CONFIGURATION' }); + }); + + await t.test('wrapTool uses shared kernel and exact frozen effect arguments', async () => { + const c = await provision(), id = randomUUID(); let count = 0; + const wrapped = wrapTool(async args => { assert(Object.isFrozen(args)); count++; return args; }, { + operationId: input => input.id, effect: input => ({ tool: 'fixed.account-A', args: input }), authority: authorityFor(c), + }); + assert.deepEqual(await wrapped({ id, amount: 1 }), { id, amount: 1 }); + await wrapped({ id, amount: 1 }); assert.equal(count, 1); + await assert.rejects(wrapped({ id, amount: 2 }), { code: 'CONFLICT' }); + }); +}); diff --git a/sdk/typescript/test/postgres-continuity-witness.test.mjs b/sdk/typescript/test/postgres-continuity-witness.test.mjs new file mode 100644 index 00000000..1557e536 --- /dev/null +++ b/sdk/typescript/test/postgres-continuity-witness.test.mjs @@ -0,0 +1,145 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import pg from 'pg'; +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { createPostgresContinuityWitness, createPostgresExecutionAuthority, protectToolCall } from '../dist/index.js'; +const connectionString = process.env.ONCE_TEST_POSTGRES; +if (!connectionString) throw new Error('Disposable PostgreSQL required; proof never skips'); +test('durable independent witness and bounded authority admission', async t => { + const admin = new pg.Pool({ connectionString }); + const names = [0, 1].map(() => `once_witness_${randomUUID().replaceAll('-', '')}`); + const pools = []; + for (const name of names) { + await admin.query(`CREATE DATABASE ${name}`); + const url = new URL(connectionString); url.pathname = `/${name}`; + pools.push(new pg.Pool({ connectionString: url.href, connectionTimeoutMillis: 1000 })); + } + const [pool, witnessPool] = pools; + t.after(async () => { for (const p of pools) await p.end(); for (const n of names) await admin.query(`DROP DATABASE ${n}`); await admin.end(); }); + await pool.query(readFileSync(new URL('../sql/execution-authority-v1.sql', import.meta.url), 'utf8')); + await witnessPool.query(readFileSync(new URL('../sql/continuity-witness-v1.sql', import.meta.url), 'utf8')); + await witnessPool.query("INSERT INTO once_continuity.metadata VALUES(true,'witness-1',1)"); + const witness = createPostgresContinuityWitness({ pool: witnessPool, expectedWitnessId: 'witness-1' }); + const cp = (id, revision) => ({ authorityId: id, generation: 'g1', epoch: '1', revision: String(revision) }); + async function provision() { + const id = randomUUID(); + await pool.query("INSERT INTO once_execution.authorities VALUES($1,1,'g1',1,0)", [id]); + await witnessPool.query("INSERT INTO once_continuity.checkpoints VALUES($1,'g1',1,0)", [id]); + return { pool, witness, authorityId: id, expectedGeneration: 'g1', expectedEpoch: '1' }; + } + await t.test('concurrent CAS has exactly one durable winner and no implicit checkpoint recreation', async () => { + const { authorityId: id } = await provision(); + const results = await Promise.all(Array.from({ length: 12 }, () => witness.compareAndAdvance(cp(id, 0), cp(id, 1)))); + assert.equal(results.filter(Boolean).length, 1); + assert.equal(await witness.compareAndAdvance(cp(id, 0), cp(id, 1)), false); + assert.equal(await witness.compareAndAdvance(cp('missing', 0), cp('missing', 1)), false); + assert.equal((await witnessPool.query('SELECT count(*)::int AS n FROM once_continuity.checkpoints WHERE authority_id=$1', ['missing'])).rows[0].n, 0); + await assert.rejects(witness.compareAndAdvance(cp(id, 1), cp(id, 0)), { code: 'INVALID_WITNESS_REQUEST' }); + const restarted = createPostgresContinuityWitness({ pool: witnessPool, expectedWitnessId: 'witness-1' }); + assert.equal(await restarted.compareAndAdvance(cp(id, 1), cp(id, 2)), true); + }); + await t.test('wrong witness identity and shared pool deny', async () => { + const cfg = await provision(); + assert.throws(() => createPostgresExecutionAuthority({ ...cfg, pool: witnessPool }), { code: 'INVALID_CONFIGURATION' }); + const wrong = createPostgresContinuityWitness({ pool: witnessPool, expectedWitnessId: 'wrong' }); + await assert.rejects(wrong.compareAndAdvance(cp(cfg.authorityId, 0), cp(cfg.authorityId, 1)), { code: 'STALE_WITNESS' }); + }); + await t.test('existing kernel replays and conflicts using separate durable SQL witness', async () => { + const cfg = await provision(); let effects = 0; + const run = amount => protectToolCall({ operationId: 'refund', effect: { tool: 'fixture.refund', args: { amount } }, authority: createPostgresExecutionAuthority(cfg), execute: async () => { effects++; return { receipt: 'r1' }; } }); + assert.deepEqual(await run(1), { receipt: 'r1' }); assert.deepEqual(await run(1), { receipt: 'r1' }); + await assert.rejects(run(2), { code: 'CONFLICT' }); assert.equal(effects, 1); + }); + await t.test('restricted runtime roles require row-lock privilege and cannot provision or erase authority', async () => { + const roles = ['execution', 'witness'].map(kind => `once_${kind}_${randomUUID().replaceAll('-', '')}`); + const runtimePools = []; + try { + // Disposable fixture credential only; CI uses password authentication, + // while local trust fixtures must not hide missing runtime-role passwords. + for (const role of roles) await admin.query(`CREATE ROLE ${role} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD 'fixture-only-runtime'`); + // These databases are this test's disposable resources only. Explicitly + // scope CONNECT so the fixture also exercises separate runtime principals. + for (let i = 0; i < names.length; i++) { + await admin.query(`REVOKE CONNECT ON DATABASE ${names[i]} FROM PUBLIC`); + await admin.query(`GRANT CONNECT ON DATABASE ${names[i]} TO ${roles[i]}`); + const url = new URL(connectionString); url.pathname = `/${names[i]}`; url.username = roles[i]; url.password = 'fixture-only-runtime'; + runtimePools.push(new pg.Pool({ connectionString: url.href, connectionTimeoutMillis: 1000 })); + } + const [executionRuntime, witnessRuntime] = runtimePools; + await pool.query(`GRANT USAGE ON SCHEMA once_execution TO ${roles[0]}; + GRANT SELECT, UPDATE(revision) ON once_execution.authorities TO ${roles[0]}; + GRANT SELECT, INSERT, UPDATE(state,result_json,owner,lease_until) ON once_execution.operations TO ${roles[0]}`); + await witnessPool.query(`GRANT USAGE ON SCHEMA once_continuity TO ${roles[1]}; + GRANT SELECT ON once_continuity.metadata TO ${roles[1]}; + GRANT SELECT, UPDATE(revision) ON once_continuity.checkpoints TO ${roles[1]}`); + const flags = await admin.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=ANY($1)', [roles]); + assert.equal(flags.rows.length, 2); + assert.ok(flags.rows.every(row => Object.values(row).every(value => value === false))); + // PostgreSQL FOR SHARE requires UPDATE on at least one column, even + // though this code never updates witness metadata. Prove SELECT alone + // fails, then grant only the CHECK-constrained version column. + await assert.rejects(witnessRuntime.query('SELECT witness_id,schema_version FROM once_continuity.metadata WHERE singleton=true FOR SHARE'), { code: '42501' }); + await witnessPool.query(`GRANT UPDATE(schema_version) ON once_continuity.metadata TO ${roles[1]}`); + const cfg = await provision(); + const restrictedWitness = createPostgresContinuityWitness({ pool: witnessRuntime, expectedWitnessId: 'witness-1' }); + const authority = createPostgresExecutionAuthority({ ...cfg, pool: executionRuntime, witness: restrictedWitness }); + let effects = 0; + const run = amount => protectToolCall({ operationId: 'restricted-refund', effect: { tool: 'fixture.refund', args: { amount } }, authority, + execute: async () => { effects++; return { receipt: 'restricted-r1' }; } }); + assert.deepEqual(await run(1), { receipt: 'restricted-r1' }); + assert.deepEqual(await run(1), { receipt: 'restricted-r1' }); + await assert.rejects(run(2), { code: 'CONFLICT' }); + assert.equal(effects, 1); + const denied = async (runtime, statements) => { + for (const statement of statements) await assert.rejects(runtime.query(statement), { code: '42501' }, statement); + }; + await denied(executionRuntime, [ + 'CREATE TABLE once_execution.forbidden(id text)', + 'DELETE FROM once_execution.operations', + 'TRUNCATE once_execution.operations', + "INSERT INTO once_execution.authorities VALUES('forbidden',1,'g1',1,0)", + "UPDATE once_execution.authorities SET generation='forbidden'", + 'CREATE SCHEMA forbidden', + `CREATE ROLE ${roles[0]}_forbidden`, + ]); + await denied(witnessRuntime, [ + 'CREATE TABLE once_continuity.forbidden(id text)', + 'DELETE FROM once_continuity.checkpoints', + 'TRUNCATE once_continuity.checkpoints', + "INSERT INTO once_continuity.checkpoints VALUES('forbidden','g1',1,0)", + "UPDATE once_continuity.metadata SET witness_id='forbidden'", + ]); + await assert.rejects(witnessRuntime.query('UPDATE once_continuity.metadata SET schema_version=2'), { code: '23514' }); + assert.equal(effects, 1); + } finally { + for (const runtime of runtimePools) await runtime.end(); + for (const role of roles) { + await pool.query(`DROP OWNED BY ${role}`); + await witnessPool.query(`DROP OWNED BY ${role}`); + await admin.query(`DROP ROLE ${role}`); + } + } + }); + await t.test('lease expiring during witness acknowledgement cannot dispatch', async () => { + const cfg = await provision(); const store = await createPostgresExecutionAuthority(cfg).open(); + await store.reserve('id', 'fingerprint', 'owner', 100); + const slow = { async compareAndAdvance(a,b) { await new Promise(r => setTimeout(r, 150)); return witness.compareAndAdvance(a,b); } }; + const stale = await createPostgresExecutionAuthority({ ...cfg, witness: slow }).open(); + await assert.rejects(stale.assertDispatch('id', 'fingerprint', 'owner'), { code: 'EXECUTION_RIGHT_LOST' }); + await assert.rejects(store.reserve('new', 'f', 'o', 1000), { code: 'CONTINUITY_LOST' }); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM once_execution.operations WHERE authority_id=$1', [cfg.authorityId])).rows[0].n, 1); + }); + await t.test('lost acknowledgement is bounded; late CAS never enables retry', async () => { + const cfg = await provision(); let calls = 0; + const late = { async compareAndAdvance(a,b) { calls++; await new Promise(r => setTimeout(r, 80)); return witness.compareAndAdvance(a,b); } }; + const store = await createPostgresExecutionAuthority({ ...cfg, witness: late, witnessTimeoutMs: 20 }).open(); + const start = performance.now(); + await assert.rejects(store.reserve('id', 'f', 'o', 1000), { code: 'CONTINUITY_UNAVAILABLE' }); + assert.ok(performance.now() - start < 500); + await new Promise(r => setTimeout(r, 120)); assert.equal(calls, 1); + const normal = await createPostgresExecutionAuthority(cfg).open(); + await assert.rejects(normal.reserve('id', 'f', 'o', 1000), { code: 'CONTINUITY_LOST' }); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM once_execution.operations WHERE authority_id=$1', [cfg.authorityId])).rows[0].n, 0); + }); +}); diff --git a/sdk/typescript/test/shared-authority-worker.mjs b/sdk/typescript/test/shared-authority-worker.mjs new file mode 100644 index 00000000..9fd8045a --- /dev/null +++ b/sdk/typescript/test/shared-authority-worker.mjs @@ -0,0 +1,34 @@ +// Separate application process. Provider and witness live outside this process. +import pg from 'pg'; +import { writeSync } from 'node:fs'; +import { createPostgresExecutionAuthority, protectToolCall } from '../dist/index.js'; + +const config = JSON.parse(process.env.ONCE_TEST_WORKER); +const pool = new pg.Pool({ connectionString: process.env.ONCE_TEST_DATABASE, max: 2 }); +const witness = { async compareAndAdvance(expected, next) { + const response = await fetch(`${config.fixture}/cas`, { method: 'POST', body: JSON.stringify({ expected, next }) }); + if (!response.ok) throw new Error('witness unavailable'); + return (await response.json()).accepted; +} }; +const shared = createPostgresExecutionAuthority({ pool, witness, authorityId: config.authorityId, expectedGeneration: 'generation-1', expectedEpoch: '1' }); +const authority = config.crashBefore ? { async open() { + const store = await shared.open(); + return { ...store, async reserve(...args) { + await store.reserve(...args); + writeSync(1, 'CRASH_BEFORE_PROVIDER_DISPATCH'); process.kill(process.pid, 'SIGKILL'); + } }; +} } : shared; +try { + const result = await protectToolCall({ + operationId: config.id, effect: { tool: 'fixture.account-A.refund', args: { id: config.id, amount: config.amount ?? 100 } }, + authority, leaseMs: config.leaseMs ?? 30000, + execute: async ({ args }) => { + const response = await fetch(`${config.fixture}/effect`, { method: 'POST', body: JSON.stringify(args) }); + const receipt = await response.json(); + if (config.crash) { writeSync(1, 'CRASH_AFTER_PROVIDER_COMMIT'); process.kill(process.pid, 'SIGKILL'); } + return receipt; + }, + }); + console.log(JSON.stringify({ result })); +} catch (error) { console.log(JSON.stringify({ code: error.code })); } +finally { await pool.end(); }