Repository navigation
Commit 395ca21
Fixes #23686
Clause-②: no
The runtime HTTP dispatcher's shared thrown-error writer,
`HttpDispatcher.errorFromThrown`, now answers the engine's
`DuplicateRecordError` with the ruled wire spelling, `409
UNIQUE_VIOLATION`. It carries the REST arm's fields in `error.details`:
`developerMessage`, `object`, and `field` when the driver named the
column.
## Governing text
Maintainer ruling, recorded in commit `65846bc46`, as restated at
`packages/metadata-protocol/src/protocol.ts`:
> Maintainer ruling (2026-09-03, recorded in commit 65846bc): one wire
spelling on every route, `UNIQUE_VIOLATION`; the row reports it too.
The engine's thrown identity is not renamed: `DuplicateRecordError.code`
stays `DUPLICATE_RECORD` in-process. A producer that merely speaks
`DUPLICATE_RECORD` (a handler or hook throwing the registered code
without being the engine's class) keeps its own code.
## What changed
`packages/runtime/src/http-dispatcher.ts`, one branch in
`errorFromThrown`, decided before the code is written:
- **Code.** `isEngineDuplicateRecordEnvelope(e) ? 'UNIQUE_VIOLATION' :
thrown.code`. The key is the existing `@objectstack/types` predicate.
`RestServer`'s arm, the import runner's rows and both Hono doors (PR
#23682) use the same key: the registered code AND the class name, never
message text. There is no second translator.
- **Details.** For that class only, the new module-local
`duplicateArmContext` adds the fields that `structuredCodeAnswer` (the
REST arm, an existing `@objectstack/types` export) states beside its
`UNIQUE_VIOLATION`. It drops the arm's own `error` sentence and `code`.
The fields are added only when the arm agrees with the envelope on
status and code, the Hono doors' agreement rule. The fields are read off
that one classification and never listed. They go in `details`, the
dispatcher envelope's own context slot (`buildApiError`,
`error-envelope.ts`), where `issues` and `fields` already ride.
- **Unchanged.** The status (409), `message` (the thrown one, as at the
Hono doors), `declaredCode` (absent: `DUPLICATE_RECORD` is registered,
so nothing is demoted), `userMessage`, and every other code at this
writer.
- No export is added. No `packages/types` or `packages/spec` file moves.
`packages/runtime/src/domains/packages.ts` is not touched.
## Measured before and after (H1)
Rig (scratch, not committed): a real `ObjectQL` over `SqlDriver`
(better-sqlite3 `:memory:`), a real `ObjectStackProtocolImplementation`,
and the real `HttpDispatcher`, driven through `dispatch()` with a real
Fetch `Request`. The identity step is the one stubbed seam. The object
`udoc` declares `email` unique, and the write repeats it. Before is at
`c9810dd9d`, after is at `ab389b338`.
| Door | Before | After |
|---|---|---|
| `POST /actions/udoc/dup`: a code-registered handler whose
`ctx.engine.insert` repeats `email` | `409
{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate
record refused on 'udoc': a unique constraint already holds these
values. No record was written.","httpStatus":409}}` | `409
{"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate
record refused on 'udoc':
…","httpStatus":409,"details":{"developerMessage":"Duplicate record
refused on 'udoc': …","object":"udoc"}}}` |
| `POST /actions/udoc/set_email/r3`: a declarative `operation: 'update'`
action (caller identity) whose write repeats `email` | the same
`DUPLICATE_RECORD` body | the same `UNIQUE_VIOLATION` body |
| `POST /security/_activation/position/sales_rep`: 5 concurrent first
flips (catalog activation) | 1 × `200`, 4 × `409 DUPLICATE_RECORD` on
`sys_metadata_activation`, no `details` | 1 × `200`, 4 × `409
UNIQUE_VIOLATION`, `details` `developerMessage` and `object:
"sys_metadata_activation"` |
| `POST /actions/_activation/udoc/dup`: 5 concurrent first flips (action
activation, ledger attached) | 1 × `200`, 4 × `409 DUPLICATE_RECORD` | 1
× `200`, 4 × `409 UNIQUE_VIOLATION`, the same `details` |
| `createHonoApp` `POST /api/v1/actions/udoc/dup` (the public door,
runtime `dist` rebuilt with the fix) | `409 DUPLICATE_RECORD` with
`httpStatus`, as measured on the card at `9a6754fcd` | `409
{"success":false,"error":{"code":"UNIQUE_VIOLATION",…,"httpStatus":409,"details":{"developerMessage":…,"object":"udoc"}}}`
|
None of the measured bodies has a `field`, and none is invented. The
unique index on this tenancy-scoped object is the composite
`uniq_udoc_organization_id_email`, and the ledger's is `(metadata_type,
name)`, so the driver names no single column. The pin below states a
producer-named `field` and finds it in `details`.
## Domain census (H2, H3)
| Domain (its catch arm hands the throw to `errorFromThrown`) | Verdict
|
|---|---|
| `actions`: the declared-status arm, for a handler write and a
declarative update | crosses: measured, pinned |
| action activation (`handleActionActivationWrite`) | crosses when a
flip loses a race on the ledger key: measured, pinned |
| catalog activation (`catalog-activation.ts`) | crosses when a flip
loses a race on the ledger key: measured, pinned |
| `meta` save (`PUT /meta/:type/:name`) | not crossing. 5 concurrent
first saves: 2 × `200`, 3 × `409 METADATA_CONFLICT`. The protocol's own
optimistic check answers, and no `DuplicateRecordError` reaches the arm
|
| `automation` flow create (`POST /automation`) | not crossing. It saves
through the same protocol path: 5 concurrent creates gave 2 × `200`, 3 ×
`409 METADATA_CONFLICT` (an automation double for the engine
registration, with the real protocol and store) |
| `mcp` (the transport catch in `mcp.ts`) | not crossing this writer. A
tool failure is a JSON-RPC `200` that `@objectstack/mcp` renders, so it
never reaches this catch. See "Found, not fixed here" |
| `/packages` (install and the rest) | NOT MEASURED.
`domains/packages.ts` is held by #23546, and this rig composes no
package service. Its catch arms hand throws to this writer, so a
`DuplicateRecordError` there gets the same branch. No `/packages` pin
was added |
| `security` audience-binding suggestions | NOT MEASURED: the
`plugin-security` service is not composed in this rig |
| a `type: 'flow'` action body | NOT MEASURED: `service-automation`
cannot be composed from `packages/runtime`. By reading, a failed run is
`400 FLOW_FAILED` (`classifyFlowRefusal`), an envelope built in the
domain (H3) |
| permission-set clone, `share-links`, `ui` | not crossing, by reading.
The clone saves create-only through `saveMetaItem` (the measured
`METADATA_CONFLICT` path) and maps that code to its own name-taken
refusal. `share-links` writes a generated token, so no request repeats a
unique value. `ui` only reads (`getUiView`) |
H3: the actions domain's sanitised crash arm and its flow-refusal arm
build their own envelopes. Neither produces this class.
## Controls
| Control | Before (the ablation run) | After |
|---|---|---|
| A handler throws `Object.assign(new Error(…), { code:
'DUPLICATE_RECORD', status: 409 })`, which is not the engine's class |
`409 DUPLICATE_RECORD`, no `details`, no `declaredCode` | the same |
| A handler's `ctx.engine.delete` hits a `restrict` lookup (another
engine 409) | `409 DELETE_RESTRICTED`, no `details` | the same |
## Pins
The new file
`packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts` has
7 rows on a real engine. Codes and key presence are asserted, never
wording.
- Actions door: a handler write, a declarative update, and the engine's
class stating `field: 'email'`. Each answers `409 UNIQUE_VIOLATION` with
`httpStatus` 409. `details` keys are exactly `developerMessage` and
`object` (plus `field` on the third row), with no `declaredCode`.
Neither `DUPLICATE_RECORD` nor the driver's text appears anywhere in the
body, and the store is unchanged.
- Catalog activation and action activation: the second of two flips
loses the ledger key. The pin holds the race window open: the engine's
ledger read misses the row the other flip inserted, and every other
call, the insert included, reaches the real engine. Each answers `409
UNIQUE_VIOLATION`, and the ledger keeps one row.
- CONTROL: a producer speaking `DUPLICATE_RECORD` keeps it.
- CONTROL: `DELETE_RESTRICTED` keeps its body.
## Ablation
At `ab389b338`, the run used `node scripts/ablation-replace.mjs` in wrap
mode, under an outer `trap … EXIT INT TERM` that restores the absolute
path. Both fix lines were replaced by `const code = thrown.code;` with
the marker `ABLATION_23686_NO_WIRE_MAP`, and by a constant-undefined
`armContext`.
- On disk, the anchor went from 1 to 0 and the marker from 0 to 1. The
blob went from `77430131ece1` to `f03e88487cdf`.
- The pins import `./http-dispatcher.js` from source, so no rebuild was
needed.
- Result: `Tests 5 failed | 2 passed (7)`. Exactly the 5 positive rows
were red, each with `expected 'DUPLICATE_RECORD' to be
'UNIQUE_VIOLATION'`. Both controls stayed green.
- Restore: the blob equals HEAD (`77430131ece1`) and `git diff HEAD` is
empty on the path.
## Tests
At the final head `84c825f5a` (`origin/main` `3bc407d30` merged before
opening; it touches `objectql` and `metadata-protocol`, and none of the
changed files overlap this diff), after a cache-backed `pnpm turbo run
build --filter='!@objectstack/docs' --concurrency=2` (`75 successful`):
- `pnpm --filter @objectstack/runtime test`: `Test Files 389 passed
(389)`, `Tests 5870 passed | 19 skipped (5889)`.
- `pnpm --filter @objectstack/runtime test:repo`: `Test Files 3 passed
(3)`, `Tests 808 passed (808)`.
- `pnpm --filter @objectstack/runtime typecheck`: exit 0. That covers
`tsc --noEmit` and `check:test-typecheck: OK`. The new test file
compiles under `tsconfig.test.json` and adds no debt.
- Consumer suites that read dispatcher error bodies:
`@objectstack/http-conformance`, `13` files, `144 passed`;
`@objectstack/hono`, `8` files, `161 passed`. No consumer test pins
`DUPLICATE_RECORD` at a dispatcher door (`git grep` over every package's
tests).
- `eslint --no-inline-config packages/runtime`: exit 0, no findings.
Repo-wide `pnpm lint` is CI's.
## Gates
At `84c825f5a`:
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derived 62 commands from the
3 changed paths (merge base `3bc407d30`). All 62 ran, and each exit code
was recorded before any pipe. All 62 exited 0.
- `--ran` reconciliation: `62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN`.
The zero is derived: all 62 lines carry an exit code.
- Among them, all exit 0: `pnpm check:dispatcher-error-vocabulary`,
`pnpm check:test-source-alias`, `pnpm check:engine-double-contract`,
`pnpm check:cross-package-test-inputs`, `pnpm check:nul-bytes`, `pnpm
check:doc-authoring`, `pnpm check:issue-citations`, `pnpm
check:dual-build-cjs-loads`, `check-changeset-no-major`,
`check-adr-0087-registration` and `check-empty-changeset`.
- `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`
(exit 3) on the pre-merge pass, before the full build. It exited 0 once
the build was in place and again at this head.
- Every family CI runs on this PR is reported on its check-runs.
## Found, not fixed here
1. **The MCP door relays the engine's in-process spelling inside a tool
result.** Class (b), measured at a public door. `POST /api/v1/mcp` with
`tools/call` `create_record`, repeating a unique value, answered `200`.
The JSON-RPC result was `isError: true`, and its text was
`{"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused
on 'udoc': …","status":409}}`. The rig was the real `MCPServerRuntime`
from `packages/mcp`, a real engine, and the dispatcher at `ab389b338`.
The writer is `@objectstack/mcp`'s `thrownRefusalEnvelope` (through
`errorResultWithCode` and `errorResultFromThrown`), which relays
`err.code` verbatim. It is not this writer. `Seam: spec:ErrorCode
UNIQUE_VIOLATION (ruling 65846bc) →
runtime:packages/mcp/src/mcp-http-tools.ts thrownRefusalEnvelope |
consumer: MCP clients`.
2. **A sandboxed script body that lets the engine's refusal propagate
keeps `DUPLICATE_RECORD`, before and after this PR.** Class (b),
measured at a public door. `POST /actions/udoc/sbx` ran a `type:
'script'` body that calls `ctx.api.object('udoc').insert(…)` and does
not catch. It answered `409
{"code":"DUPLICATE_RECORD","message":"DuplicateRecordError: Duplicate
record refused on 'udoc': …","httpStatus":409}`. The refusal leaves the
VM as `SandboxError`, with `code` and `status` lifted and the class name
gone, so the shared key does not match. By reading, `RestServer`'s arm
and the Hono doors key the same way (not measured there). The
client-facing message also carries the `DuplicateRecordError: `
class-name prefix. `Seam: spec:ErrorCode UNIQUE_VIOLATION (ruling
65846bc) → runtime:packages/runtime/src/sandbox/quickjs-runner.ts
SandboxError relay + @objectstack/types isEngineDuplicateRecordEnvelope
| consumer: every door`. Whether a relayed, uncaught engine envelope
counts as "the engine's class" under the ruling is a question for the
seat. The ruling's docblock speaks of a producer that *throws* the code.
A fix belongs upstream (the relay or the key), not in a second
translator at a door.
## Acceptance notes
- `dispatcher-plugin`'s `errorResponseBase`, the exit for a throw that
leaves `dispatch()`, does not carry this translation. No measured door
hands it the engine's class: the write paths of the routes it serves are
caught in their domains and answered here. The divergence is declared in
`errorFromThrown`'s docblock rather than closed by a second translator.
- At this door, as at the Hono doors, `details.developerMessage` repeats
`message` byte for byte for this code. The arm relays the engine's
sentence, and the dispatcher keeps the thrown `message`.
- In an activation race, the losing flip now answers `409
UNIQUE_VIOLATION` instead of `409 DUPLICATE_RECORD`. It is still refused
rather than applied, and a retry lands as an update. That behaviour is
unchanged and is an observation only.
- Docs: no edit. At the dispatcher's doors, the "every route answers the
wire code `UNIQUE_VIOLATION`" sentences in `error-handling.mdx` and
`error-catalog.mdx` now hold. "the in-process spelling never crosses
HTTP" stays false for the two findings above, both outside this writer,
so the docs follow their fixes.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 954bf72 commit 395ca21
3 files changed
Lines changed: 395 additions & 4 deletions
File tree
- .changeset
- packages/runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
0 commit comments