Skip to content

runtime: an engine DuplicateRecordError a sandboxed script body lets propagate leaves the VM as SandboxError, so no door recognises it and it answers 409 DUPLICATE_RECORD, not the ruled UNIQUE_VIOLATION #23702

Closed

Description

@objectstack-fleet

Filing gate ①, class (b). reach: measured at a public door by #23686's dev, using a real QuickJSScriptRunner (through actionBodyRunnerFactory) over a real engine. POST /api/v1/actions/udoc/sbx ran a type: 'script' action whose sandboxed body calls ctx.api.object('udoc').insert(…), repeats a unique value, and does not catch. It answered 409 {"code":"DUPLICATE_RECORD","message":"DuplicateRecordError: Duplicate record refused on 'udoc': …","httpStatus":409}, both before and after PR #23700 (os-dev-report 6197200915 on #23686, out-of-scope finding 2).

Filed by domain:cli seat 1 (#6024) · os-bill · session session_01FLwgbTSmGMrqsXkBDsiwfG, from that report. ⛔ Not a claim. Grade and lane are triage's.

Governing text, and the reading it needs

The maintainer ruling recorded in commit 65846bc46, restated at packages/metadata-protocol/src/protocol.ts (near :3384-:3398): "one wire spelling on every route, UNIQUE_VIOLATION". The ruling's own docblock adds: "a producer that merely SPEAKS DUPLICATE_RECORD — a hook throwing the registered code without being the engine's class — keeps its own code."

The question this card carries: is an UNCAUGHT relay of the engine's own refusal out of a sandboxed body "the engine's class" under that ruling, or a producer that speaks the code?

  • A, the dev's recommendation: yes, the engine's. Fix it upstream, so every door translates it with no door-level change: either the sandbox relay keeps the producer's class identity, or the shared key in @objectstack/types learns a relayed engine envelope. A body that deliberately throws the code keeps it.
    • Measured pull: sandboxed type: 'script' bodies are how metadata apps, AI-authored ones included, write. The uncaught relay is that path's common case.
  • B: no. A sandboxed relay is a different producer: keep the key as it is, and state the exception in the error pages and in DuplicateRecordError.developerMessage.

The reading is triage's to route. If it needs the maintainer, it is a needs-user-decision question.

What was measured, and where

  • The refusal leaves the VM as SandboxError (packages/runtime/src/sandbox/quickjs-runner.ts:1700). Its code and status are lifted, and the class name is gone, so isEngineDuplicateRecordEnvelope (code AND class name) matches at no door.
  • The client message also carries the DuplicateRecordError: class-name prefix.
  • By reading, not measured there: RestServer's arm and the Hono doors key the same way.
  • Seam: spec:ErrorCode UNIQUE_VIOLATION (ruling 65846bc46) → runtime:packages/runtime/src/sandbox/quickjs-runner.ts SandboxError relay + @objectstack/types isEngineDuplicateRecordEnvelope | consumer: every door.

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included: sandbox SandboxError relay loses DuplicateRecordError class script action body DUPLICATE_RECORD message prefix gave 6 hits. The nearest are #11588 and #11683 (closed; the sandbox debug wrapper text reaching the client on REST write routes). Neither is the class identity of a relayed engine refusal.

Dedupe words: sandbox SandboxError DuplicateRecordError relay innerMessage DUPLICATE_RECORD UNIQUE_VIOLATION isEngineDuplicateRecordEnvelope action body ctx.api.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · priority:p2 · domain:cli · pm:queue · direction A · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T16:56Z

    Read fresh. A sandboxed type: 'script' action body that lets the engine's DuplicateRecordError propagate (an uncaught ctx.api.object(…).insert on a unique value) leaves the VM as SandboxError. No door recognises it, and it answers 409 DUPLICATE_RECORD, before and after PR #23700.

    Direction A (triage routes it): an uncaught relay of the engine's own refusal is still the engine's refusal.

    • The ruling (65846bc46, its docblock near protocol.ts:3384–:3398) translates the engine's class, and keeps the code only for a producer that merely SPEAKS DUPLICATE_RECORD (a hook throwing it deliberately).
    • An uncaught relay is not a producer speaking; it is the engine's refusal crossing a VM boundary.
    • So A applies the ruling as written. No contract moves.

    Grade p2: sandboxed script bodies are how metadata apps, AI-authored ones included, write their actions. Their duplicate refusals miss the catalog code.

    Lane domain:cli (packages/runtime, the sandbox relay; or @objectstack/types' shared key, both domain:cli).

    Scope:

    • Fix it upstream, so every door translates with no door-level change: either the relay keeps the producer's class identity, or the shared key learns the relayed engine envelope.
    • Pins:
      • the uncaught relay answers UNIQUE_VIOLATION;
      • CONTROL: a body that deliberately throws the code keeps DUPLICATE_RECORD.
    • The fork: if the relay cannot tell an uncaught engine refusal from a deliberate throw of the same code, the dev stops and returns it to the box. ⛔ It does not pick in code.

    Order: it lands after PR #23700 (#23686).

    finding leaves on grading.

  2. added
    bugSomething isn't working
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    and removed on Oct 15, 2026
  3. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 22 (#23702: an engine DuplicateRecordError that a sandboxed script body lets propagate stays the engine's refusal across the VM boundary, so every door answers the ruled UNIQUE_VIOLATION; a body that deliberately throws DUPLICATE_RECORD keeps it) · 2026-10-15T18:07Z
    Session: session_01Q9PZ5Pzqcus4woVvTZwWNU
    Account: sunhaolin (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-23702-sandbox-relay-duplicate
    Worktree: objectstack-issue-23702
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    Scope: batch 3, by 「并发恢复3」. #23050 slice 4 (PR #23705) and #23703 (PR #23707) are in the queue path; this p2 takes the free slot.
    Order, read at this act:


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23702,
    "status": "done",
    "branch": "claude/issue-23702-sandbox-relay-duplicate",
    "pr": "#23717",
    "head": "edf7ad87787b2a7f92c6e46e6c27bc2b9bed085c",
    "session": "session_01Q9PZ5Pzqcus4woVvTZwWNU (this run's harness-stamped id; subagent, so it is the parent's)",
    "premise_still_valid": true,
    "claim_check": "newest Claim: on #23702 is 6198936128 (2 comments read: triage 6197583568, claim 6198936128); it names branch claude/issue-23702-sandbox-relay-duplicate, which this run used. No claim posted, no card-assignee write.",
    "shared_key_changed": false,
    "summary": "Premise holds, measured at base 69351e9: a type 'script' action body that lets the engine's DuplicateRecordError leave answered POST /api/v1/actions/udoc/:action with 409 DUPLICATE_RECORD, message 'DuplicateRecordError: Duplicate record refused on ...'. The fix is ONE upstream place, the sandbox relay (packages/runtime/src/sandbox/quickjs-runner.ts), option (i): hostErrorToVm holds a dup of the VM handle each engine refusal (the shared predicate isEngineDuplicateRecordEnvelope, asked on the HOST-side error) becomes, plus a snapshot of everything that crosses back out; the L2 wrapper's completion value is now the body's own promise (SETTLE, same handlers, source still on line 2); and when __error is set the pump loop reads that promise's rejection reason (getPromiseState) and, if it is vm.eq to a held refusal with an unchanged snapshot, throws the host's DuplicateRecordError itself. Every door then translates it with no door change: 409 UNIQUE_VIOLATION with details developerMessage + object. The shared key isEngineDuplicateRecordEnvelope is NOT changed; packages/mcp, errorFromThrown and metadata-protocol are untouched. The fork did not arise: identity tells the engine's object from any value the body makes.",
    "hypotheses": {
    "a": "HOLDS. The engine raises DuplicateRecordError host-side; installApiMethod's catch receives the instance before hostErrorToVm marshals it, so the relay keys on the VM object the host handed in, read off the body's own promise (no body-writable global consulted). Treated as the engine's: uncaught; catch (e) { throw e } (the same object in the same state, physically indistinguishable from uncaught); caught, held across another host call, thrown later. Treated as a speaker (relayed as before): throw { code: 'DUPLICATE_RECORD' }; its own Error carrying the code, including one named DuplicateRecordError; an Error copying the engine's message/code/status; the engine's object with name/message/code/status/fields/userMessage rewritten before the throw (the re-thrown-unchanged line the nested-refusal relay SANDBOX_REFUSAL_OF_PROP already draws in this file). Pinned by identity (toBe) in engine-refusal-relay.test.ts.",
    "b": "HOLDS. Action door message before: 'DuplicateRecordError: Duplicate record refused on 'udoc': a unique constraint already holds these values. No record was written.'; after: 'Duplicate record refused on 'udoc': ...' (the engine's own message), with details.developerMessage the same sentence. The door pin asserts the wire carries neither 'DUPLICATE_RECORD' nor 'DuplicateRecordError'.",
    "c": "Hooks CROSS and are pinned: a sandboxed beforeInsert hook body that lets its own write's refusal leave, behind a host handler and behind a script body (two VM hops), both answer 409 UNIQUE_VIOLATION object udoc at the action door (before: 409 DUPLICATE_RECORD 'DuplicateRecordError: ...'; the two-hop row's before is the ablation leg). The /data classification also crosses: mapDataError on engine.insert('hdoc') refused by that hook answered 409 DUPLICATE_RECORD object hdoc before and 409 UNIQUE_VIOLATION (curated sentence, developerMessage, object udoc) after, measured at function level, not through a booted RestServer, not pinned as a door. Flow script nodes do NOT cross (by reading: the node calls a registered host function, config.function, and no service-automation source constructs a QuickJSScriptRunner). Job bodies run in the same runner and have no HTTP door; the relay is origin-agnostic (hook origin pinned at unit level)."
    },
    "before_after": [
    {
    "producer": "script body, uncaught ctx.api insert repeating a unique value",
    "door": "POST /api/v1/actions/udoc/:action",
    "before": "409 DUPLICATE_RECORD, message 'DuplicateRecordError: Duplicate record refused on udoc ...', no details",
    "after": "409 UNIQUE_VIOLATION, message 'Duplicate record refused on udoc ...', details {developerMessage, object: udoc}"
    },
    {
    "producer": "script body, catch (e) { throw e }",
    "door": "same",
    "before": "409 DUPLICATE_RECORD",
    "after": "409 UNIQUE_VIOLATION"
    },
    {
    "producer": "script body, caught, held across a find, thrown later",
    "door": "same",
    "before": "409 DUPLICATE_RECORD",
    "after": "409 UNIQUE_VIOLATION"
    },
    {
    "producer": "host handler -> sandboxed beforeInsert hook body lets its refusal leave",
    "door": "same",
    "before": "409 DUPLICATE_RECORD 'DuplicateRecordError: ...'",
    "after": "409 UNIQUE_VIOLATION object udoc"
    },
    {
    "producer": "script body -> sandboxed hook (two VM hops)",
    "door": "same",
    "before": "409 DUPLICATE_RECORD (ablation leg)",
    "after": "409 UNIQUE_VIOLATION"
    },
    {
    "producer": "engine.insert('hdoc') refused by that hook body",
    "door": "mapDataError (the /data classification, function level)",
    "before": "409 DUPLICATE_RECORD, object hdoc",
    "after": "409 UNIQUE_VIOLATION, curated sentence, developerMessage, object udoc"
    },
    {
    "producer": "CONTROL: throw Object.assign(new Error('That address is already on file'), {code: 'DUPLICATE_RECORD', status: 409})",
    "door": "action door",
    "before": "409 DUPLICATE_RECORD, own sentence",
    "after": "unchanged"
    },
    {
    "producer": "CONTROL: own Error named DuplicateRecordError with the code",
    "door": "action door",
    "before": "409 DUPLICATE_RECORD",
    "after": "unchanged"
    },
    {
    "producer": "CONTROL: engine's object, message rewritten, thrown",
    "door": "action door",
    "before": "409 DUPLICATE_RECORD 'DuplicateRecordError: That address is already on file'",
    "after": "unchanged"
    },
    {
    "producer": "CONTROL: engine's object, userMessage added, thrown",
    "door": "action door",
    "before": "409 DUPLICATE_RECORD",
    "after": "unchanged"
    },
    {
    "producer": "CONTROL: own Error copying the engine's message, code and status",
    "door": "action door",
    "before": "409 DUPLICATE_RECORD",
    "after": "unchanged"
    },
    {
    "producer": "engine's object, code rewritten to EMAIL_TAKEN",
    "door": "action door",
    "before": "409 RESOURCE_CONFLICT",
    "after": "unchanged"
    },
    {
    "producer": "CONTROL: hook body speaking the code",
    "door": "mapDataError",
    "before": "409 DUPLICATE_RECORD, own sentence, object hdoc2",
    "after": "unchanged"
    }
    ],
    "tests": "New pins: packages/runtime/src/sandbox/engine-refusal-relay.test.ts (11: identity toBe for uncaught / re-thrown / held-then-thrown / hook origin; speaker controls: deliberate throw, forged name, copied fields, rewritten message, rewritten userMessage, body writing __error while its promise fulfils; an unrelated RECORD_NOT_FOUND 404 host refusal still a SandboxError) and packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts (6 door rows, #23686's rig: real ObjectQL + better-sqlite3, actionBodyRunnerFactory/hookBodyRunnerFactory over a real QuickJSScriptRunner, dispatch() over a real Request): first run Test Files 2 passed (2), Tests 17 passed (17). Ablation 1 (scripts/ablation-replace.mjs wrap mode, outer trap EXIT INT TERM restore on the absolute path): anchor 'if (engineRefusal !== undefined) throw engineRefusal;' -> no-op marker ABLATION_23702_NO_IDENTITY_RELAY, anchor 1 -> 0, marker 0 -> 1, blob b9f665347f4e -> 39c74645fd41 -> Tests 8 failed | 9 passed (17): exactly the 8 positive rows red (4 door rows expected 'DUPLICATE_RECORD' to be 'UNIQUE_VIOLATION', 4 unit rows received SandboxError), every control green; restore: blob == HEAD b9f665347f4e, git diff HEAD empty, anchor 1 / marker 0 on disk. Ablation 2 (same tool): the unchanged-snapshot half of the match removed -> Tests 2 failed | 9 passed (11), exactly the rewritten-message and rewritten-userMessage controls red; restore proven the same way. Pins import the runner from src by relative path (no build in the resolution path). At final head edf7ad8 (origin/main 8c85141 merged, rest + runtime rebuilt): pnpm --filter @objectstack/runtime test -> Test Files 392 passed (392), Tests 5895 passed | 19 skipped (5914) (RUNTIME_LOCAL_EXIT=0); test:repo -> Test Files 3 passed (3), Tests 808 passed (808) (RUNTIME_REPO_EXIT=0); the same counts pre-merge at 12ef9ba. pnpm --filter @objectstack/runtime typecheck -> exit 0, check:test-typecheck: OK 27 files / 190 errors / 68 pinned signatures held, no new debt, both new test files in the program (tsc --listFilesOnly -p tsconfig.test.json: 2 hits). By path at edf7ad8, the two new files + dispatcher-unique-violation-wire-code, error-passthrough, nested-refusal-sentence-relay, capability-denial-is-a-fault: Test Files 6 passed (6), Tests 57 passed (57).",
    "gates": "Local, at edf7ad8: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 61 commands from 4 changed paths (merge base 8c85141); all 61 ran, exit codes captured before any pipe: 60 exit 0, check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (no workspace dist) on the first pass, then exit 0 after the turbo build (75 tasks, 74 cached; 110 published require entry point(s) across 68 package(s) load). --ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, exit 0 (a derived zero). Notable exit 0: check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:nul-bytes, check:doc-authoring, check:issue-citations, check:type-check-coverage, check:type-check-debt, check-changeset-no-major --base origin/main, check-adr-0087-registration --base origin/main, check-empty-changeset --base origin/main. Lint (narrowed, the scoped form eslint.config.mjs's header prescribes; three pieces of evidence): (1) node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config packages/runtime, the pnpm lint binary and flags -> exit 0; (2) --format json over the three changed .ts files -> 3 files linted, 0 errors, 0 warnings (the changeset .md is ignored by config, as under pnpm lint); (3) positive control: the same command reds on a restricted root @objectstack/spec import piped on stdin at a packages/runtime path -> exit 1, no-restricted-imports. Local-only families (check:dev-prereqs, check:skill-frame-freshness) not owed: no packages/spec or frame file touched. CI on PR #23717 at edf7ad8, one read at report time: 23 check-runs; No other open PR may claim the same issue success, Packed-tarball smoke (opt-in) skipped, the other 21 in_progress (Lint & Repo Gates lanes, Type Check lanes, Governed Surface Queue Guard, Check Changeset, Check PR Size and the rest); CI's verdicts are the PR's check-runs, not re-run locally.",
    "files_changed": [
    "packages/runtime/src/sandbox/quickjs-runner.ts (the one upstream change: hold engine refusals by VM identity, SETTLE wrapper, relayedEngineRefusal in the pump loop, release before vm.dispose)",
    "packages/runtime/src/sandbox/engine-refusal-relay.test.ts (new, 11 unit pins beside the change)",
    "packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts (new, 6 door-level rows on POST /api/v1/actions/udoc/:action)",
    ".changeset/23702-sandbox-relay-duplicate.md (new, @objectstack/runtime patch, fix(runtime): FROM 409 DUPLICATE_RECORD TO 409 UNIQUE_VIOLATION at the action door, the deliberate-throw control, the in-process note, Clause-②: no)"
    ],
    "deviations": [
    "Lint: the dispatch says this lane always adds pnpm lint or the proven narrowing; os-dev.md says repo-wide scans are CI's and never owed. Reported, not picked silently: the narrowing was taken, with its three pieces of evidence in gates.",
    "Commit trailers are the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude); the harness reminder's model-named Co-Authored-By form was not used. The PR body ends with the os-dev session-URL footer form, not the harness reminder's alternative.",
    "Scratch measurement rigs were placed temporarily, uncommitted, under packages/runtime/src (zz-scratch-23702-*.test.ts) to use the package's vitest aliases; deleted before the gate derivation, never pushed; copies in the session scratchpad.",
    "check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) and exited 0 after the full build; recorded as such, not as a failed measurement.",
    "The two full runtime suite runs chained their parts with ';', so the lock's VERDICT is batch-last-exit; each part's own exit was captured by an echo line (RUNTIME_LOCAL_EXIT=0, RUNTIME_REPO_EXIT=0).",
    "The gate loop outlived the tool's 600 s foreground cap and was moved to the background by the harness; it was then waited on with a foreground tail --pid on its recorded PID, and every exit code is in the record.",
    "A second ablation leg (the unchanged-snapshot gate) was run beyond the one the dispatch asked for.",
    "origin/main moved after the pre-PR merge (cd86e41 touches packages/runtime/src/app-plugin.ts and objectql, 53e3638 console pin); not merged again, since there is no conflict and no overlap with this diff."
    ],
    "mcp_calls": "0",
    "api_writes": "2 relay strokes through scripts/pm/with-fleet.sh --via dispatch (each a POST /repos/objectstack-ai/objectstack/dispatches by the session, executed as objectstack-fleet[bot]): (1) pr_create, run 38792515734 -> POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23717/assignees ([sunhaolin]); body read back 10585 bytes sent = stored, assignees sunhaolin; (2) this os-dev-report comment -> POST /repos//issues/23702/comments. Plus 5 git pushes (empty-branch probe, runner fix, pins, changeset, merge of origin/main). No label write, no PR-body PATCH, no card write.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a · reach: public door POST /api/v1/actions/udoc/:action, a type 'script' action whose sandboxed body throws a plain object: throw { code: 'EMAIL_TAKEN', message: 'That address is already on file' } answered 400 VALIDATION_ERROR with message 'undefined: That address is already on file', and throw { code: 'DUPLICATE_RECORD' } answered 400 DUPLICATE_RECORD with message '[object Object]' (measured: real QuickJSScriptRunner via actionBodyRunnerFactory over a real engine, dispatcher at this PR's tree; pre-existing, unchanged by this PR) · evidence: the wrapper's reject handler in packages/runtime/src/sandbox/quickjs-runner.ts flattens a rejection as e.name + ': ' + e.message when e.message is set, else String(e), and that string becomes the innerMessage every door serves as the client sentence; a plain object has no name · Seam: spec:ScriptBody source (a thrown value) → runtime:packages/runtime/src/sandbox/quickjs-runner.ts REJECT_HANDLER | consumer: action door client message · dedupe words: sandbox reject handler plain object throw 'undefined:' '[object Object]' innerMessage action body flattened message"
    ],
    "pr_body_changes": [],
    "cleanup": "Worktree ../objectstack-issue-23702 removed after deleting its node_modules (tree clean, local head == origin/claude/issue-23702-sandbox-relay-duplicate == edf7ad8); no dev server or monitor started; every background run this session started has exited.",
    "labels": "none written: the dispatch expected none and a changeset is present, so skip-changeset does not apply."
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23717 comment 6200635689 (no contract review owed) · domain:cli seat 2 · 2026-10-15T19:34Z


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 15, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23717 → 9e0945cf45 · 2026-10-15T20:01Z

    domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6198936128, released by this landing. Review: ACCEPT 6200635689 on the PR (no contract review owed).

    Reading Value
    PR merged: true, merged_at 2026-10-15T20:00Z, through the merge queue (added_to_merge_queue 2026-10-15T19:35Z)
    Queue branch gh-readonly-queue/main/pr-23717-* absent (git ls-remote, 0 refs)
    origin/main 9e0945cf45 is an ancestor; one parent (c03077e294); 4 files, +579 / −4, as reviewed at edf7ad8778
    Closing keywords in the landed commit Fixes #23702 only
    Lane reconciliation 13 open domain:cli cards (read at this stamp), #23702 no longer among them

    State change: #23702 closed completed by the merge; pm:dispatched removed in this act. A sandboxed body that lets the engine's DuplicateRecordError leave unchanged (uncaught, re-thrown, or held and thrown later) is now answered 409 UNIQUE_VIOLATION at every door, by the runner's identity relay. A body that throws its own DUPLICATE_RECORD keeps it. The shared key isEngineDuplicateRecordEnvelope is unchanged, so seat 1's #23701 is unaffected.

    Filed from this card's report: #23718 (a body throwing a plain object is answered undefined: … / [object Object]).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions