Repository navigation
fix(cloud-connection): install-local runs the ADR-0087 D1 protocol handshake and refuses with the packages door answer (422) - #21805
Conversation
…l-local and its rehydrate POST /api/v1/marketplace/install-local now calls assertProtocolCompat before anything is registered, written or synced, and refuses an incompatible manifest with the answer POST /api/v1/packages gives: 422 OS_PROTOCOL_INCOMPATIBLE with the diagnostic's five fields in error.details. That answer is one shared helper, protocolIncompatibleAnswer, beside ProtocolIncompatibleError in @objectstack/metadata-core; the packages door's module-private copy is gone and it calls the helper too. The kernel:ready rehydrate no longer loads a ledger entry whose range excludes this runtime's major: it logs the refusal at error, naming the replay command, and the boot continues. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
…he rehydrate skip Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 88ac8501153cdc9fcffcff30ee45d9205bad8ad7 && git checkout 88ac8501153cdc9fcffcff30ee45d9205bad8ad7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d13df0c630cee5c24b03391a375941a2424dd9d2 6ca235b90b92737f4c5b8e6acf5aeb114b3438c7 && git checkout -B drift-repro d13df0c630cee5c24b03391a375941a2424dd9d2 && git merge --no-ff 6ca235b90b92737f4c5b8e6acf5aeb114b3438c7
node scripts/docs-audit/affected-docs.mjs --json d13df0c630cee5c24b03391a375941a2424dd9d2
|
Contract reviewServed-tier: Inputs: card #21762 (body and all five comments: triage ① Derived judgmentsPublic surface, per package entry (surface = what the built entry declarations reach):
Accept-set changes, each named:
Check-runs on ② Semver levelChangeset
Clause-②:
Changeset sentences against the diff: the range order matches ③ Boundary flagsDev Dev
PR body Acceptance notes, each graded:
Escalations: none. No flag needs a ruling beyond the seat's; (a), (c) and (d) are filings the seat makes, not conditions on this head. Implemented-by: VERDICT: PASS |
… decision in words instead of a tracker number (stage 15) (objectstack-ai#21810) Part of objectstack-ai#20749 Clause-②: no Stage 15 of this card: the next area of class (e), the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the first name-ordered file group directly under `packages/spec/src/data/`: the 20 test files from `aggregate-field-type-compatibility.test.ts` to `date-range-presets.test.ts`. They carried 94 messages and 102 tracker ids, citing 60 records. Every one of those ids now either states what its record decided, in words (form D), or is dropped where the title already says it. Text only: no assertion, identifier, test count or code comment changes. ## Census at the base (`0a3480311a`) Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`), `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5 `dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the copies stages 10 to 14 used. A literal counts as a test title when its folded message is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` / `.only` chains included. Everything else is an "other" string. Both instruments read **1325 messages / 1406 ids in 282 files**, the seat's reading at `0a3480311a` (stage 14's head). | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `data/` (this PR: the first 20 files) | 95 | 468 / 501 | 445 / 475 | 23 / 26 | | `ui/` | 81 | 393 / 416 | 375 / 398 | 18 / 18 | | `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 | | `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 | | (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 | | `ai/` | 1 | 2 / 2 | 0 | 2 / 2 | | `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 | | **total** | **282** | **1325 / 1406** | **1246 / 1323** | **79 / 83** | The group reads **94 messages / 102 ids in 20 files**, the seat's figures, file for file: | file (under `data/`) | messages / ids | titles | other | |:--|--:|--:|--:| | `aggregate-field-type-compatibility.test.ts` | 4 / 4 | 4 / 4 | 0 | | `analytics-date-range-closed-vocabulary.test.ts` | 1 / 1 | 1 / 1 | 0 | | `analytics-date-range-two-bound-window.test.ts` | 3 / 3 | 3 / 3 | 0 | | `analytics-query-window-integer.test.ts` | 2 / 2 | 2 / 2 | 0 | | `analytics-strictness-batchd.test.ts` | 9 / 9 | 9 / 9 | 0 | | `analytics.test.ts` | 6 / 6 | 6 / 6 | 0 | | `api-derivation.test.ts` | 6 / 6 | 6 / 6 | 0 | | `api-methods-batch-conformance.test.ts` | 3 / 4 | 1 / 1 | 2 / 3 | | `authoring-key-lint.test.ts` | 2 / 2 | 2 / 2 | 0 | | `autonumber-format.test.ts` | 3 / 3 | 3 / 3 | 0 | | `autonumber-unanchored-boundary.test.ts` | 3 / 4 | 3 / 4 | 0 | | `bulk-write-hook-conformance.test.ts` | 2 / 2 | 2 / 2 | 0 | | `calendar-day.test.ts` | 2 / 2 | 2 / 2 | 0 | | `context-tokens.test.ts` | 1 / 1 | 1 / 1 | 0 | | `currency-mode-family-closure.pin.test.ts` | 2 / 2 | 2 / 2 | 0 | | `currency-precision-iso4217.test.ts` | 7 / 7 | 7 / 7 | 0 | | `data-engine.test.ts` | 20 / 25 | 20 / 25 | 0 | | `datasource-credential-redaction.test.ts` | 6 / 6 | 6 / 6 | 0 | | `datasource.test.ts` | 10 / 10 | 10 / 10 | 0 | | `date-range-presets.test.ts` | 2 / 3 | 2 / 3 | 0 | | **20 files** | **94 / 102** | **92 / 99** | **2 / 3** | - **Controls.** Lit, a title: `data/document.test.ts` reads 2 / 2 at the head. Lit, "other" strings: the two in `data/external-lookup-retirement.test.ts` (`:89`, `:130`) still read at the head. Dark: the file comment at `data/analytics-strictness-batchd.test.ts:4` (it names the strictness batch by its number) reads 0. Planted in a scratch copy of the head `data/calendar-day.test.ts`: an id put into a title reads 1 / 1, and an id put into a comment reads 0. - **A wider pattern** (any `#` plus digits) reads the same totals in 19 of the 20 files. In `aggregate-field-type-compatibility.test.ts` it reads one more, a decision-batch number at `:150` that sits beside a cited record in the same literal. The gate's pattern needs three to five digits, so it is not counted there. - **At the head:** 1231 messages / 1304 ids in 262 files. The 20 files read 0 / 0 on both patterns, and no other file moved. ## How the area was chosen `data/` has no subdirectory to split by (449 ids directly under it, `data/driver/` 52), so its stages take name-ordered file groups near the ~100-id bound, as stage 14's report proposed. This census reads the first group at exactly 102, the claim's figure, so the rule needed no re-cut. **Named for the next stages** (re-cut from the head census, 1231 / 1304; `data/` 374 / 399 left): - `data/` in four more stages, name-ordered: 1. `default-value-shape.test.ts` to `filter-comparand-shape.test.ts`: 20 files, 94 messages / 100 ids; 2. `filter-comparand-type.test.ts` to `filter-view-operator-parity.test.ts`: 20 files, 95 / 99; 3. `filter.test.ts` to `object.test.ts`: 17 files, 103 / 114. `object.test.ts` alone carries 42, so no cut lands nearer the bound; 4. `query-transport.test.ts` to `validation.test.ts` (11 files, 34 / 34) with `data/driver/` (7 files, 48 / 52): 86 ids. - `ui/` 416, about four stages. `api/` 201, two. `system/` 165, two. The files directly in `src/`, 120, one. - The three docblock needles (`ai/build-progress.test.ts:236`, `:237`, `contracts/approval-service.test.ts:274`), one stage with their docblocks. ## What each id became 24 literals (28 ids) now state a decision in words. 2 literals (2 ids) get their subject back in words where the number stood in for it. 69 literals (72 ids) drop a number the title already explains. (95 literals in 94 messages: the `sys_organization` reason string is one message over two lines.) Every cited record was read with its comments through REST: 55 answer 200. objectstack-ai#6345, objectstack-ai#8876, objectstack-ai#9040, objectstack-ai#10194 and objectstack-ai#17014 answer 404, and their decisions were read from what landed: `e2798fa` (one driver vocabulary for start and migrate), `d634e66` (the username half of the URL userinfo grammar), `2420641` (a credential in the mongo `options` passthrough is refused), `2306a76` (`theme` / `analytics_cube` validated at the `/meta` write door) and `80aef80` (a one-day window for the one-day presets), each with its CHANGELOG entry. No cross-repo record is cited in this group. | record(s) | literal (under `data/`) | now reads | |:--|:--|:--| | objectstack-ai#11152 | `aggregate-field-type-compatibility.test.ts:150` | "accepts `sum` / `avg` / `min` / `max` over booleans — numbers on every backend, a ruling that outranks the refused-by-default rule". The maintainer ruled that booleans aggregate as numbers on every backend; decision batch 80 held that ruling over batch 59's blanket refusal of unnamed pairs. That batch number went with the id. | | objectstack-ai#4001 (3) | `analytics-strictness-batchd.test.ts:83`, `:248`, `:306` | "batch D, unknown keys refused — …" before "the doors the cube family is reachable through", "alias claims are true of the surfaces they point at" and "deliberate non-closures (re-verdicts, not omissions)". The campaign's decision: an unknown key is refused, not stripped. | | objectstack-ai#3878 (2) | `analytics-strictness-batchd.test.ts:270`, `:297` | "matching the dispatcher's bespoke hint at the /analytics entry" and "the retired-envelope tombstones still fire". The body is the bare `AnalyticsQuery`; the `{ cube, query }` envelope was retired with tombstones, and the entry answers 400 with a hint at `where`. | | objectstack-ai#18612 | `analytics.test.ts:314` | "a persisted cube heals at the door — the retired join `sql` / `relationship` are stripped (ADR-0087 D2)". | | objectstack-ai#3391 | `api-derivation.test.ts:16` | "api-derivation — one table resolves the effective operations from six primitives". The server is the only adjudicator, through one derivation table. | | objectstack-ai#3543 | `api-derivation.test.ts:286` | "vocabulary split — authors write six primitives, the wire speaks operations". The authored enum shrank; the wire vocabulary stayed byte-stable. | | objectstack-ai#15873 | `api-methods-batch-conformance.test.ts:221` | A declared reason string: "(a ruling grants `update`; both are column-clamped per row by ADR-0092 D2)". Option (a), decision batch 64. | | objectstack-ai#3786 | `authoring-key-lint.test.ts:37` | "lintAuthoredRecordKeys — an unknown authoring key is reported, not swallowed", the decision its source docblock records. | | objectstack-ai#6555 | `autonumber-format.test.ts:23` | "DEFAULT_AUTONUMBER_FORMAT / resolveAutonumberFormat — one declared default both sides read". Route 3: `{0000}` became the contract default, and both fallbacks went away. | | objectstack-ai#5038 | `bulk-write-hook-conformance.test.ts:114` | "records the after half as DELIVERED — the engine fires it once per row". | | objectstack-ai#5574 | `bulk-write-hook-conformance.test.ts:119` | "records the before half as DELIVERED — the engine dispatches it per row too". | | objectstack-ai#20126 | `currency-mode-family-closure.pin.test.ts:348` | "currency-mode family — the enumerating closure pin: `defaultCurrency` holds only under `fixed`". | | objectstack-ai#19992 | `currency-precision-iso4217.test.ts:163` | "the removed `currencyConfig.precision` at rest: a stored row carrying the baked `precision: 2` is served canonical". | | objectstack-ai#7918 | `currency-precision-iso4217.test.ts:224` | "… where the ISO 4217 width check used to refuse it". That check was the record's option A, later reversed. | | objectstack-ai#3407, objectstack-ai#6437 | `data-engine.test.ts:1185` | "DroppedFieldsEventSchema.reason — why a write dropped submitted fields, widened past the readonly pair". | | objectstack-ai#6262, objectstack-ai#6433, objectstack-ai#6435 | `data-engine.test.ts:1198` | "primary_key is the value the engine reports when it strips a payload id it ruled is not an identifier", the schema's own wording of that strip on the bulk and the by-id paths. | | objectstack-ai#8300 | `datasource-credential-redaction.test.ts:70` | "(the drift guard on the one credential-key definition)". | | objectstack-ai#8876 | `datasource-credential-redaction.test.ts:232` | "— the username half of the same alignment". | | objectstack-ai#8337 | `datasource-credential-redaction.test.ts:243` | "redactUrlCredentialQueryParams — the read half: a credential query parameter is never served back". | | objectstack-ai#8153 | `datasource.test.ts:673` | "— unchanged by the managed-row credentialsRef allowance". The ruling allowed `external.credentialsRef`, and only it, on managed rows. | | objectstack-ai#4614, objectstack-ai#8793 | `date-range-presets.test.ts:14` | "date-range preset vocabulary — one source of truth, read by both the UI and the data side". | **Subject restored (2 ids):** objectstack-ai#20126 at `currency-mode-family-closure.pin.test.ts:403` ("currency-mode closure controls — each rule can fail, and passes what it must") and objectstack-ai#7918 at `currency-precision-iso4217.test.ts:311` ("carries the measured anchors — 0 digits for JPY, 2 for USD, 3 for KWD"). That literal moved from double to single quotes, since it no longer holds an apostrophe. **Dropped only (72 ids):** objectstack-ai#1603, objectstack-ai#2377, objectstack-ai#3026, objectstack-ai#3391, objectstack-ai#3543, objectstack-ai#3545, objectstack-ai#3795 (9), objectstack-ai#4001 (2), objectstack-ai#4286, objectstack-ai#4346 (2), objectstack-ai#4538, objectstack-ai#4583, objectstack-ai#5586, objectstack-ai#6345, objectstack-ai#6555, objectstack-ai#6560, objectstack-ai#7178 (5), objectstack-ai#7265, objectstack-ai#7287 (2), objectstack-ai#7802 (2), objectstack-ai#8032, objectstack-ai#8057 (2), objectstack-ai#8153 (7), objectstack-ai#8336, objectstack-ai#8337, objectstack-ai#9040, objectstack-ai#10194, objectstack-ai#10414, objectstack-ai#13802, objectstack-ai#16041, objectstack-ai#16632, objectstack-ai#17014, objectstack-ai#17296, objectstack-ai#17598 (2), objectstack-ai#18278, objectstack-ai#19992 (3), objectstack-ai#20011, objectstack-ai#20300 (2), objectstack-ai#20550, objectstack-ai#20600, objectstack-ai#20808 (3), objectstack-ai#21365 (2). - Each of these titles already states the decision it pins: for example "empty array → deny-all (flipped semantics)" for objectstack-ai#3391, "accepts the BARE query string — the canonical ADR-0061 D1 spelling" for objectstack-ai#7178, or "`currencyConfig.precision` is removed: refused with the prescription, whatever its value" for objectstack-ai#19992. - **Small rewordings that carry no new claim:** `analytics-strictness-batchd.test.ts:307` reads "are CLOSED now" where it named the record; `autonumber-unanchored-boundary.test.ts:51` reads "(ruled: mixed content is out of contract)"; `datasource.test.ts:553` reads "(the happy path)". The circled part numbers after objectstack-ai#17598 went with the id. - **The two `api-methods-batch-conformance.test.ts` reason strings** (`sys_api_key`, `sys_organization`) end "rather than hitting /batch." now. The table is read only through `!== undefined`, so no assertion reads their text. ## Readers - **Test-name filters:** none. A tracked-tree search for `-t` and `--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t "owner-scoped"`), which is unrelated. - **Snapshots:** none. No `__snapshots__` directory exists under `data/`, and no `.snap` file is tracked under `packages/spec`. - **Projects:** two touched files are listed in `packages/spec/vitest.repo-tests.json`: `api-methods-batch-conformance.test.ts` and `currency-mode-family-closure.pin.test.ts`. Both were run in the `repo` project at the base and at the head, and the other 18 in `local`. - **By substring:** every old literal, plus a window around each id (289 needles), was searched across the tracked tree outside its own file. No gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads one. The 14 hits are: - **sibling titles in other lanes:** `service-analytics` `aggregate-nontemporal-measure-refusal.test.ts:344` and `objectql` `engine-autonumber-default-format.test.ts:248`; - **this card's later `data/` stage:** `data/driver/postgres.test.ts:169`, the same "placeholders are not resolved here" title, already in the census; - **comments, CHANGELOG, an audit ledger and liveness evidence:** `lint` `validate-dataset-measure-aggregates.test.ts:179`, `service-analytics` `dataset-compiler.ts:227`, `objectql` `engine.ts:6206` and `:6272`, `analytics.zod.ts:1002`, `docs/audits/2026-07-unknown-key-strictness-ledger.md:728`, two `packages/spec/CHANGELOG.md` entries and the `liveness/field.json:218` evidence string, which quotes the `engine.ts` comment. None reads a test title. - **Same-text titles named in stage 14's ACCEPT** (`(objectstack-ai#15680)`, `(objectstack-ai#5955)`, `objectstack-ai#3896 close-out`): none falls in this group. ## Text-only proof Stage 10's scratch tool (`textonly10.cjs`, md5 `d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file on three legs: 1. **Skeleton:** the full AST, with string pieces masked. It must be identical. 2. **Comments:** every comment, byte-equal. 3. **Strings:** each changed string leaf must sit in a test-call title position or on a declared line, must carry a tracker id before, and must carry no `#` plus digits after. The declared lines are the three reason-string leaves in `api-methods-batch-conformance.test.ts`. - **Result:** 20 of 20 files SAME on all three legs, as predicted in writing before the run. - **Totals:** 95 changed literals, 92 titles and 3 declared. The diff's `+` and `-` lines are exactly the 95 planned lines, and every file keeps its line count. - **Controls (10 of 10 as predicted, on scratch copies, each anchor hit once):** identifier rename DIFF; numeric literal DIFF; comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten title given a new id VIOLATION; a title that was id-free at base edited VIOLATION; one title reverted to base SAME; a declared string given a new id VIOLATION; an undeclared `expect` message changed VIOLATION; a title re-split into a `+` chain DIFF. **Test counts:** the 20 files were run at the base, in a separate base worktree, and at the head, with `--project local --project repo`. Both sides read 553 / 553 passed, with the same count and status sequence per file in 20 of 20. 291 full test names change, and each equals the base name with the planned replacements applied (0 mismatches). No full name repeats on either side. ## `main` merged in, once objectstack-ai#21800 (the console pin bump) landed while this branch was being verified, and it rewrites the comment block at `:61-77` of `api-methods-batch-conformance.test.ts`. This PR edits only string literals in that file, more than 100 lines below the block, so `origin/main` (`18c2ddc1ec`, which also carries objectstack-ai#21801) was merged in with a plain merge, no rebase, and no conflict. The PR's delta against `main` is still exactly the 20 files, +95 / -95. Every reading in this body was re-taken on the merged head `bf16ad1190`, against `18c2ddc1ec` as the base: the census (1325 / 1406 there, 1231 / 1304 here, unchanged by the two commits), the text-only proof and its controls (the three declared lines now sit at `:202`, `:230` and `:235`), the 20-file runs, the full build, the suite, the typecheck and the gates. Re-fetched just before this PR opened, `origin/main` was one commit further (`75ddcd1b41`, objectstack-ai#21805, in `cloud-connection`, `metadata-core` and `runtime`). It touches no `packages/spec` path and no file here, so it was not merged. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the 20 touched files are in it, and no `*.test.ts` at all. Of `src/`, only the `*.zod.ts` sources ship: the controls `src/data/analytics.zod.ts`, `src/data/data-engine.zod.ts` and `dist/data/index.js` are in it. - In the built `dist/`, five new phrases and four old literals each read in 0 files. The control `Unrecognized key(s) on` reads in 42. So this PR publishes nothing, and no changeset is added. ## Verification (at `bf16ad1190`) - `pnpm turbo run build` over all packages: 71 / 71 (also 71 / 71 at the pre-merge head `89c4b300c2`). - `@objectstack/spec`: - `vitest run --project local`: 615 files, 18360 passed, 1 todo. - `typecheck` exit 0, including `check:test-typecheck` (52 files / 246 errors / 135 pinned signatures held). Its program holds all 20 touched files, counted with `tsc --listFilesOnly -p tsconfig.test.json`. - `check:generated`: all 15 generated artifacts up to date after the merge. - **Gates:** `dispatch-gates --commands` derived 79 families, the same set as stages 13 and 14, and all 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. - The five roster families whose rosters sit under a touched directory were also run, and each exits 0: `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - **ESLint, a proven narrowing:** `--no-inline-config` over the 20 files reads 0 errors and 0 warnings. The population comes from ESLint's own config: 20 configured, 0 ignored. No file sets `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 190 changed lines. ## Acceptance notes - **No needle in this group.** Every id was a title or a declared reason string; no expected value of an assertion over a source docblock was found. The three known needles are untouched. - **Same-id test titles in other packages** are their lanes' test-string shares. A search of `describe` / `it` / `test` lines outside `packages/spec` finds 156 lines citing ids this PR handled, in 79 files of 23 packages: `objectql` 73 (29 files), `rest` 18 (7), `runtime` 7 (4), `plugin-security` 6 (5), `cli` 6 (3), `lint` 6 (4), `service-datasource` 6 (3), `driver-sql` 4 (4), `platform-objects` 4 (2), `plugin-approvals` 3 (2), `service-automation` 3 (2), `driver-mongodb` 3 (1), `plugin-auth` 3 (1), `service-analytics` 3 (3), `metadata-core` 2 (1), `plugin-hono-server` 2 (1), and one each in `client`, `triggers`, `core`, `metadata-protocol`, `qa/dogfood`, `types` and `driver-memory`. - **Two spec test files outside `src/`** carry same-id titles: `packages/spec/scripts/file-description.test.ts:66` and `packages/spec/scripts/format-type.test.ts:85`. They are outside class (e) as ruled ("the test strings shipped under `src/`"). - **Numeric delivery fields:** `bulk-write-hook-conformance.test.ts:115-116` and `:129-130` assert `engineDeliveryIssue: 5038` / `5574`, numbers in the source contract table. They are not strings, the gate's pattern cannot see them, and they are not this card's share. - **Code comments still carry ids** in these files and their sources, for example the header of `analytics-strictness-batchd.test.ts` and the `SINGLE_RECORD_WRITE_ONLY` comments in `api-methods-batch-conformance.test.ts`. Comments are not this card's share, and none is touched here. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ Co-authored-by: Claude <noreply@anthropic.com>
…ers 400 SMS_SERVICE_REQUIRED instead of a bare 500 (objectstack-ai#21858) Fixes objectstack-ai#21793 Clause-②: yes (widening) ## What changed `POST /api/v1/auth/phone-number/send-otp` on a deployment with phone sign-in on but no SMS service that can deliver a code (none wired, or only the log transport in production) answered **500 with an empty body**. It now answers **400** with `{ "code": "SMS_SERVICE_REQUIRED", "message": "..." }`. - `packages/plugins/plugin-auth/src/auth-manager.ts`: the no-provider branch of `deliverPhoneOtp` throws better-auth's `APIError('BAD_REQUEST', { message, code: 'SMS_SERVICE_REQUIRED' })`, the same construction the sibling refusals in this file use (for example `PASSWORD_POLICY_VIOLATION`). The message names the missing SMS delivery service and where an administrator configures it (Setup, Settings, SMS Delivery), and points the user at phone and password sign-in. It never carries the one-time code. The quota branch and the delivery path are unchanged. Four doc comments that said `NOT_SUPPORTED` now name the new answer. - `packages/spec/src/api/error-code-ledger.zod.ts`: `SMS_SERVICE_REQUIRED` is registered for `@objectstack/plugin-auth`, beside `EMAIL_SERVICE_REQUIRED`. The reference pages `content/docs/references/api/{contract,error-code-ledger}.mdx` are regenerated (`gen:docs`, as `check:generated` named). - `content/docs/permissions/authentication.mdx`: the two passages that said "fails loudly with `NOT_SUPPORTED`" now state the 400 and the code. The first also says that `request-password-reset` keeps answering `{status:true}`. - `docs/qa/platform-checklist/areas/identity-auth.json`: `identity-auth.auth-method-matrix` (revision 5) names the shipped refusal in clause 4, its negative, step 4, the fixtures row and the variant. A bare 500 is now named a FAIL, and the clause cites the door pin. - Changeset: `@objectstack/spec` **minor** (the ledger accepts one more value), `@objectstack/plugin-auth` **patch** (the bug fix). ## H2: which branch, and the measurement that decided it **Branch (b): a new registered code.** No registered code honestly names "phone OTP needs an SMS delivery service" (measured at merge base `6fb71152c`): - `@objectstack/plugin-auth`'s row: `EMAIL_SERVICE_REQUIRED` names the email service. `PHONE_NOT_ENABLED` means the phone plugin is off, which is not this case. `INVITE_SMS_FAILED` is a failed invitation send. No other row is about SMS. - The standard catalog has no SMS member. `SERVICE_UNAVAILABLE` and `NOT_IMPLEMENTED` misname the cause and are 5xx. - No other package's row names SMS delivery. The spelling `SMS_SERVICE_REQUIRED` already exists in this package: `sendPhoneInviteSms` throws it as a plain-`Error` prefix for the same condition. So one name now covers one condition. It passes the objectstack-ai#8211 synonym rule, because the token `SMS` is in no standard member. The status is **400**, the status of the email sibling (`admin-import-users.ts` answers `EMAIL_SERVICE_REQUIRED` with `fail(400, ...)`). ## Mechanism hypotheses, measured All door readings use the real `AuthManager.handleRequest` over the installed better-auth 1.7.3 / better-call 1.4.0. - **H1, confirmed.** Before the fix, the no-provider branch answered `500`, no `content-type`, body `""` (measured under the ablation below). The quota branch answered `429`, `application/json`, `{"message":"Too many verification codes requested. Please try again later."}`, with no `code` field. After the fix, no-provider answers `400`, `application/json`, `{"message":"Phone verification codes are unavailable: ...","code":"SMS_SERVICE_REQUIRED"}`. The quota answer is unchanged. - **H2:** see above. - **H3:** 400, from the email sibling. - **H4, confirmed** at objectui `9dfaca65` (the `.objectui-sha` pin). `packages/auth/src/createAuthClient.ts` `postPhoneNumberEndpoint` reads the top-level `payload.code` and `payload.message` of this vendor-shaped body. `LoginForm.handleSendOtp` shows `errorMessages[code]` if one is mapped, and the message otherwise. Before the fix the payload was `null`, so the user saw "Auth request failed with status 500". ## Tests - New `packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts` is the door pin. It sends a real request through a real better-auth pipeline and a pinned memory engine. It checks: - With no SMS service: `400`, `code === 'SMS_SERVICE_REQUIRED'`, and `ErrorCode.safeParse(code)` succeeds, so the code is registered. - The refusal text never contains the OTP that better-auth stored. - With `NODE_ENV=production` and a log-only transport: the same 400 and code, and nothing is sent. - `request-password-reset` for a registered number still answers `200 {status:true}`. A pass-through spy proves the route reached the refusing send. - Outside production, a log-only transport still delivers. - In `auth-manager.test.ts`, the old `rejects.toThrow(/NOT_SUPPORTED/)` case became two cases on the error object: `isAPIError`, `BAD_REQUEST`/400, `body.code`, and no code in the message. - **Ablation.** The plain `Error` was put back through `scripts/ablation-replace.mjs` (mutation landed: anchor 1 to 0, blob `9d24bb3f` to `9c6b1b90`). Result: **5 failed / 282 passed**. That is 3 door cases (`expected 500 to be 400`, and the deliver spy rejects with `Error: NOT_SUPPORTED...` instead of the 400 shape) and 2 unit cases (`isAPIError` false, `statusCode` undefined). The tool's restore leg proved blob == HEAD `9d24bb3f` and an empty `git diff HEAD`. The first attempt was refused by the tool before any test ran, because the replacement text contained the anchor. It was redone with a whole-block anchor. - `pnpm --filter @objectstack/plugin-auth exec vitest run`: 120 files, 2515 passed, 10 skipped (at `b37edd67`). Typecheck exit 0. After the last test-file edit, the two changed files were re-run at `3e8ab846`: 286 passed. - `pnpm --filter @objectstack/spec exec vitest run`: 668 files, 19286 passed, 1 todo (at `b37edd67`). Typecheck exit 0 (at `3e8ab846`). - `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up to date, after a spec rebuild on the final merge `c6b17163`. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `c6b17163` derived 124 commands. All 124 were run on that head and exited 0. `--ran` with the exit codes recorded: 124 derived, 124 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass (at `b37edd67`), `check:engine-double-contract` and `check:objectql-double-limit` were red on the new test's engine double. The double now applies `limit`/`offset` by presence, and `--write` recorded the pinned double (additions only). The local scope is the targeted set above; the rest of the farm is CI's. ## Riding comments (domain:spec pointer 5989650462) These are comment-only edits in `error-code-ledger.zod.ts`. No code, status or owner moved. Each claim was checked against the tree: 1. `DRIVER_UNAVAILABLE` (`@objectstack/cloud-connection`): **rewritten.** Measured: the only emitter is the purge-sample-data door (`marketplace-install-local-plugin.ts`, the `!ql || !metadata` branch, 500, introduced by objectstack-ai#21773). So the comment now states that condition, rather than adding it as an "also". 2. `RESEED_SKIPPED`: **not edited. The claim does not hold on this tree.** Since objectstack-ai#21780 (`e09f1aca`), a walled session with no active organization gets `403 PERMISSION_DENIED` on the purge (and on the reseed), through `NO_ACTIVE_ORGANIZATION_CODE`. `RESEED_SKIPPED` is now emitted only by the reseed, for its other declines. The existing comment ("reseed declined to run; message carries why") is accurate. 3. `OS_PROTOCOL_INCOMPATIBLE` (`@objectstack/metadata-core`): **rewritten** to name both doors. `POST /api/v1/packages` (`runtime/src/domains/packages.ts`) and, since objectstack-ai#21805, `POST /api/v1/marketplace/install-local` both answer through the shared `protocolIncompatibleAnswer`. ## Acceptance notes - The quota branch answers 429 with **no** `code` in its body (measured above). This is deliberate: `auth-manager.test.ts` pins `bodyCode: undefined`, so both walls look the same from outside. It is untouched here. - `sendPhoneInviteSms` still throws a plain `Error('SMS_SERVICE_REQUIRED: ...')` when no SMS service is wired. No door reaches that throw, because its one caller gates on `isSmsServiceAvailable()` first. It is the delivery path, so it was out of scope and is unchanged. - Files outside the expected surface, all made false or required by this change: the checklist item, the two regenerated reference pages, and `scripts/engine-double-contract.pinned.json`. That last one records the new pinned test double, written by `check-engine-double-contract.mjs --write`, additions only. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21762
Clause-②: yes (widening)
What changes
POST /api/v1/marketplace/install-localnow runs ADR-0087 D1's protocol handshake, and itskernel:readyrehydrate does too. Onorigin/mainboth loaded a package built for another protocol major.packages/cloud-connection/src/marketplace-install-local-plugin.ts, step 1c). The route callsassertProtocolCompatright after the manifest id is parsed. That is before the unrunnable-code judgement, the collision check, the posture gate,manifest.register, the ledger write andsyncSchemas. A refusal answers422 OS_PROTOCOL_INCOMPATIBLEwith the handshake's message anderror.details: { requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }. The answer is the same on the inline-manifest branch and the cloud-snapshot branch. A missing or unreadable range is still admitted, and the handshake's warning goes toctx.logger.warn.kernel:ready,checkProtocolCompatjudges each ledger entry beforeregister. Anincompatibleentry is not loaded: nothing is registered, synced, bound or seeded for it. Oneerrorline names the code, the package, the handshake's message (which ends with the replay command) and the two remedies (install a compatible version, or DELETE). The boot continues, and the entry stays in the ledger. A missing or unreadable range rehydrates as before, with no new warning.packages/metadata-core/src/protocol-handshake.ts).protocolIncompatibleAnswer(err: ProtocolIncompatibleError): ProtocolIncompatibleAnswerreturns{ status, code, message, details }.detailsis a closed shape with the five members named one by one. It sits besideProtocolIncompatibleErrorandisProtocolIncompatibleError. The packages door's module-privateprotocolIncompatibleAnswer(deps, err)is deleted, andpackages/runtime/src/domains/packages.tsnow calls the shared helper. Both doors recognise the error with the shared brand predicate and shape it with the shared helper, so no second copy is left.@objectstack/cloud-connectionnow imports@objectstack/metadata-corefrom production source, so the package moves fromdevDependenciestodependencies(pnpm-lock.yaml: only that importer hunk). No new package enters the install closure, becauseruntimeandcorealready depend on it.Rulings applied (triage 5982323247 and its unlock 5986276908)
assertProtocolCompatbefore anything is registered, written or synced.details. Shared, not copied.ProtocolIncompatibleErrorinmetadata-core, the measured common ancestor ofruntimeandcloud-connection(see H4).Mechanism assumptions, measured at BASE
e27a7c0c9egit grep -E "assertProtocolCompat|checkProtocolCompat|OS_PROTOCOL_INCOMPATIBLE"over the plugin: 0 hits. The same grep hitsruntime/src/domains/packages.tsandmetadata-core/src/protocol-handshake.ts.metadata-core's.entry hasexport * from './protocol-handshake.js', which already exportedassertProtocolCompat,checkProtocolCompat,isProtocolIncompatibleErrorandProtocolIncompatibleError.protocolIncompatibleAnswer(deps, err)atpackages.ts:607was module-private and tookDomainHandlerDeps.cloud-connectionlistedmetadata-coreonly indevDependencies. Itsvitest.config.tsalready aliases@objectstack/metadata-coreto source, so thecheck:test-source-aliasledger does not move. Neitherruntimenorcorere-exportsmetadata-core, so a direct edge is the only import path. That path does not cross a layering gate:check:lean-entry-closureguards only@objectstack/objectql/core.check:undeclared-dep-importsis green with the edge.status,error.code,error.messageanderror.details. The parity case below asserts this. The envelopes differ in one member: the dispatcher's builder addserror.httpStatusto every error it emits, and install-local's hand-built bodies have never carried it on any exit. Both parse asApiErrorSchema. See the acceptance notes.start+kernel:ready, with a ledger entryengines.protocol: ^16). Registered ids:[marketplace-installed-ui, com.example.qaold].syncSchemascalls: 1.logger.error: [].logger.warn: [].logger.info:rehydrated com.example.qaold@1.0.0. The install reproduced the card: 200, registered, ledger file written, 1 sync.Clause-② reading: built declaration closure, before and after
The dist of
metadata-core,runtimeandcloud-connectionwas built at BASE and again at HEAD. The TypeScript checker walked each entry: the exported names, plus every declaration reachable through members, parameters, return types and heritage.@objectstack/metadata-core.: grows by exactly two names. There are 169 exports before and 171 after. The two new ones areinterface ProtocolIncompatibleAnswer { status: ProtocolIncompatibleError['status']; code: ProtocolIncompatibleError['code']; message: string; details: PickofProtocolIncompatibleDiagnosticover'requiredRange' | 'rangeSource' | 'protocolVersion' | 'targetMajor' | 'migrateCommand'}anddeclare function protocolIncompatibleAnswer(err: ProtocolIncompatibleError): ProtocolIncompatibleAnswer. Every type reachable through them (ProtocolIncompatibleError,ProtocolIncompatibleDiagnostic,RangeSource) was already exported, and its declaration text is unchanged. The 24 external references are identical. FiveSysMetadata*Objectdeclarations hash differently only because the emitted field-type union prints its members in a different order ("user" | "code"becomes"code" | "user"). The member set is the same.@objectstack/metadata-core./testing: the closure is identical.@objectstack/runtime.:index.d.tsandindex.d.ctsare byte-identical before and after (sha256 prefixcb442723451fa416). The 513 exports are unchanged.@objectstack/cloud-connection.: the 40 exports are unchanged.MarketplaceInstallLocalPlugin's declaration gains one untypedprivate reportProtocolIncompatibleEntry;and doc text. The class already had private members, so its assignability does not move.yes (widening)holds, formetadata-coreonly. The door's accept set narrows, but it narrows back to a declared contract: ADR-0087 D1 checks "the package installer", andPOST /api/v1/packagesalready refused the same manifest. I read that as outside Clause-② (execution-duties.md: 条款②只指已发布契约面,拉回已声明契约不触它). The seat should confirm this; I did not take the(narrowing)arm.metadata-coreminor,cloud-connectionpatch,runtimepatch. All three are in the fixed group.metadata-core.exports after this change (order-insensitive; the two new names areProtocolIncompatibleAnswerandprotocolIncompatibleAnswer):Tests (HEAD
6ca235b9)marketplace-install-local-protocol-handshake.test.ts(new, 11 pins):BaseResponseSchema,envelopeViolations,ApiErrorSchema),OS_PROTOCOL_INCOMPATIBLE, exactly the fivedetails. Nothing registered, no ledger file, 0 syncs.^17answersVALIDATION_ERROR.^17control: 200, registered, written, synced.[protocol]warning on the plugin logger.POST /api/v1/packages, driven through the runtime's realHttpDispatcher, and install-local give byte-equal{status, code, message, details}.errorline naming the code, the id andobjectstack migrate meta --from 16. The boot continues (a compatible entry rehydrates and the routes mount). DELETE still removes the entry, and a^17version replaces it.protocol-handshake.test.ts(+3): the helper's status, code and message; exactly fivedetailsmembers valued from the diagnostic; closed shape (a member added to the diagnostic does not leak).packages-install-protocol-incompatible.test.ts: the header's "only HTTP door" claim is updated. Its 6 pins still pass unchanged, so the packages door's wire is the same after the switch to the helper.metadata-core18 files / 374 passed.cloud-connection39 / 477 passed.runtime325 / 4624 passed (19 skipped).typecheckexits 0 for all three, and--listFilesconfirms both new test files are in their programs.Ablations (
scripts/ablation-replace.mjs, each restored to the HEAD blob withgit diff HEADempty)2ce1f2e4→e1c43b2a. 6 of 11 went red. The inline refusal readexpected 200 to be 422, the card's defect. The ordering pin readVALIDATION_ERROR, the no-range warning count read 0, and parity failed. The 5 rehydrate and control pins stayed green.expected [ …(2) ] to not include 'com.example.qaold') and boot-continues. The DELETE and replace preservation pins stayed green.detailsturned into a spread of the diagnostic. My first attempt was a no-op: the replacement contained the anchor, the tool counted it 1 → 1 and refused, and nothing ran. Re-anchored, the mutation landed (blob062e9469→7a0a3d18): 2 of 25 went red inmetadata-core(exact members, closed shape) and 5 of 11 incloud-connection. Parity went red too, because install-local read the aliased mutated source while the packages door read the built dist.src, or through the existingmetadata-coresource alias.Gates (HEAD
6ca235b9)node scripts/pm/dispatch-gates.mjs --commandsderived 74 commands from this change set. All 74 ran, and--ranreports: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN.check-plugin-teardown-shape --self-testneeded its pinned positive-control commit in this shallow clone; after fetching it, 48 cases passed.check:dual-build-cjs-loadsneeded every package'sdist/; afterpnpm build(72/72 tasks), it exited 0.pnpm lint(the fulleslint . --no-inline-config) exited 0 with no findings.origin/mainhas moved tod13df0c6(3 commits). None of them touchesmetadata-core,runtime,cloud-connectionorpnpm-lock.yaml, so I did not merge.Acceptance notes
packages/spec/src/api/error-code-ledger.zod.ts: theOS_PROTOCOL_INCOMPATIBLErow's comment still says "The one HTTP door that reaches the throw,POST /api/v1/packages". There are two doors now. That file belongs to thedomain:specseat, so this PR does not edit it.packages/runtime/src/app-plugin.test.ts(the 422 boot-seam case): its comment callsAppPlugin"the one other caller ofassertProtocolCompat". There are three callers now. This is comment drift and is not edited here.GETinstall-local listing still serves an entry the rehydrate refused, because it reads the ledger. Only theerrorlog says the entry is not loaded. This matches the posture this door already keeps for an unreadable ledger entry (log, wire unchanged).POST /api/v1/packagesandAppPluginjudge the same scope.error.httpStatus, and the dispatcher's carry it on every exit. This predates this PR and is door-wide.Generated by Claude Code