Skip to content

Commit 8d1fb1f

Browse files
refactor(rest): move registerApprovalsEndpoints into approvals-routes.ts, with one registrar list (#23050 slice 3) (#23670)
Part of #23050 Clause-②: no Slice 3 of the `rest-server.ts` split, by slice 1's pattern (landed note `6136102437`, steps 1-6) with slice 2's getter refinement (`6189011700`): the body of `RestServer#registerApprovalsEndpoints` (the approval request list and read, the `approve` / `reject` decisions, `recall`, the `revise` / `resubmit` flow moves, the `reassign` / `remind` / `request-info` / `comment` thread interactions and the audit trail; 12 routes from 7 registration sites) moves, unedited, to the new module `packages/rest/src/approvals-routes.ts`. It is move-only. Routes, the route ledgers and the published `dist/*.d.ts` are byte-identical, and every reader whose reading included a moved line now reads it where it is. The slice also adds the one list the seat ruled for (`6189011700`, answering PR #23642's open question 2): `packages/rest/src/rest-server-registrar-files.ts`, the files `RestServer`'s registrars live in. The whole-file readers of `rest-server.ts` import it, keep their sums over it, and pin its length. It is not a gate. `rest-server.ts` goes from 12,520 to 12,070 lines. The card stays open for the later slices. ## The pattern, applied **Members the moved body reads**, from an AST walk of the method at base `c7dd48ab4` (line numbers there): fields `routeManager` :1960 and `approvalsServiceProvider` :2031; methods `enforceAuth` :2406 and `resolveExecCtx` :2806. That is 4 members (7 reads each of `routeManager`, `enforceAuth` and `resolveExecCtx`, 2 of `approvalsServiceProvider`). The body has no bare `this` and no element access on `this`. **Module-level names the body reads:** `APPROVAL_REQUEST_LIST_PARAMS`, `UNDECLARED_WHOLE_NUMBER_PARAM` and `readDeclaredQueryNumber`, all three already exported from `rest-server.ts`; and `strandedDecisionDetails` and `sendError` (as `sendEnvelopeError`) from `@objectstack/types`, `classifiedRefusalAnswer`, `nestedRefusalEnvelope`, `sandboxFaultAnswer` and `handleRouteError` from `./error-response.js`, `logError` from `./log.js`, `refuseRepeatedQueryParams` and `refuseUnknownQueryParams` from their modules. **No export was added** anywhere. `strandedDecisionDetails` was used only by the moved body, so its import and its comment move with it, and `rest-server.ts` keeps a two-line pointer comment where it was. **Host shape (slice 1's shape B):** the class keeps `private registerApprovalsEndpoints(basePath)` with its name, docblock and position. Its body is now one `ApprovalsRouteHost` literal and `registerApprovalsEndpoints.call(host, basePath)`. Each interface member is typed as the class member (an indexed access on `RestServer`). Methods go as late-bound closures, `(...a) => this.m(...a)`. **Fields, checked one by one:** | field | read when | reassigned after registration? | handed over as | |---|---|---|---| | `routeManager` | registration only (every `register` call, the three route factories' included, since each factory runs during registration) | its one swap (`registerMetadataEndpoints`) wraps `registerMetadataEndpointsInner`, not this registrar | value | | `approvalsServiceProvider` | **request time** (`resolveService`, from every handler) | not by the class (constructor only), but the field is not `readonly` | **getter** | The getter is measured, not argued. A one-off test boots a source `RestServer` with no provider, drives `GET /api/v1/approvals/requests/:id`, then assigns `approvalsServiceProvider` on the instance AFTER `registerRoutes()` and drives it again: - this head: `501 NOT_IMPLEMENTED`, then `200 {"id":"r1","status":"pending"}`. The door reads the live field, as it did when it read `this.approvalsServiceProvider` on the server. - by-value ablation (`scripts/ablation-replace.mjs`, the getter replaced by `approvalsServiceProvider: this.approvalsServiceProvider,`; anchor 1 to 0, replacement 0 to 1, read back on disk inside the run): `501`, then `501` again. The value shape would freeze the field at registration. Restored by the tool and by a trap: blob `da10d3841b4d` equals `HEAD`, `git diff HEAD` empty, the one-off test file deleted. ## The registrar list `packages/rest/src/rest-server-registrar-files.ts` exports `REST_SERVER_REGISTRAR_FILES` (`rest-server.ts`, `data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`), its element type, and the repo-relative directory for readers outside the package. It is test-side only: nothing in `src/index.ts` reaches it, so tsup never emits it (measured: `dist/` names it nowhere), the same placement as `src/http-request-test-builder.ts`. The five zero-contribution whole-file readers now read every file on it. Each keeps its sum and pins the length (4). The moved body contributes zero to each, measured per file with each reader's own rule at base and head: | reader | reads | sum over the list | what pins it | |---|---|---|---| | `rest-server-query-number-census.test.ts` | numeric coercions | 9 = 8 + 1 + 0 + 0 | new case: length 4, files in list order, 9 sites; the ledger is unchanged | | `rest-server-canonical-query-ast.test.ts` | `query:` slots | 4 = 1 + 3 + 0 + 0 | per-file floors keyed by the list's own type; new control: length 4, floor keys equal the list, census order, sum 4 | | `meta-draft-read-door-census.test.ts` | draft switches | 8 = 8 + 0 + 0 + 0 | new case: length 4, files in list order, 8 sites; the node helpers now read each node's own source file; the ledger is unchanged | | `rest-server-docblock-position.test.ts` | stacked docblocks, per-module floors | floors 4 + 3 + 3 = 10 over the three modules | floors keyed by the list's type; new case: length 4, floor keys equal the list less `rest-server.ts`, sum 10 | | dogfood realtime tripwires (`authz-conformance.test.ts`) | transport mechanics and route paths | 0 hits over the four files | the two `rest-server.ts` probes gain `alsoReads` (every other list file); `probeSource` reads them with `file`; the ZERO case pins length 4 and that each probe's text is exactly the four files joined | The tripwire probes' `file` stays `packages/rest/src/rest-server.ts` and their keys are unchanged, so the probe-table census (`entries 25 / files 20 / keys 33`) and the matrix header's count do not move. Three readers that slices 1 and 2 extended by hand, because the moved lines contributed to them, now read the list too rather than gaining a fourth name: `execctx-consumer-census.test.ts`, the two `ui-view-route-*.measurement.test.ts` files and the dogfood blind-spot census's `rest-server.ts` row (sums below). ## Proof of move-only - **`.d.ts`**: `packages/rest/dist/index.d.ts` and `index.d.cts` are byte-identical to base `c7dd48ab4`, sha256 `7489de84bc6feccb7cb1f28bcbe4a97775324959c61c9bfc35e8a8c45a6f3bc9` for all four files (`cmp` silent), rebuilt at the final head `9305039c6`. The ESM and CJS bundles load, 39 exports each, one sorted export-name list (sha256 `b3246be6a171...` base and head). `packages/rest/src/index.ts` is untouched. The bundle JS bytes differ, because code moved within it. - **Routes** (base `c7dd48ab4`, head `9305039c6`): a source `RestServer` on a recording mock server, with a protocol that implements every member, dumped under three configs. `getRoutes()` (functions recorded as arity), the ordered server registration calls, and a whitespace-free source hash of every handler are byte-identical base vs head, 9 dumps of 9: - default: routes `ee3a764e19bd...ecde0d`, calls `3c5754404a85...6319dd`, handler sources `236986995419...40ef05` (75 routes, 12 approvals) - scoped-optional: `eb7a21beb5de...56d66a` / `64c05f47329e...6b5f00` / `6e7abd35ba0c...70ab2c` (150 routes, 24 approvals) - scoped-required: `d6d0e5068384...9263e6` / `8ede6c93247d...a94b02e` / `54656e8e8abf...ac66c424` (75 routes, 12 approvals) - **Route ledgers**: `packages/rest/src/rest-route-ledger.ts`, `packages/runtime/src/route-ledger.ts` and `packages/rest/src/index.ts` are untouched. - **Moved body**: base `rest-server.ts` lines 11887-12347 (`git show c7dd48a:packages/rest/src/rest-server.ts | sed -n 11887,12347p`), with one 4-space indent level removed (`sed 's/^ //'`), against `approvals-routes.ts` lines 87-547: `diff` exits 0 and prints nothing (461 lines). The body holds no multi-line string or template literal, so re-indenting changes no runtime string. - **Moved-line accounting** (`git diff --color-moved=plain --color-moved-ws=allow-indentation-change c7dd48a HEAD` over the two files): 464 deleted and 471 added lines are detected as moved. `rest-server.ts` is +15 / -465: the 12-line stub, the new import plus its comment (2), and the 2-line pointer comment replacing the `strandedDecisionDetails` import (5 lines); one `};` line the diff keeps in place. `approvals-routes.ts` adds the module docblock, the imports, the `ApprovalsRouteHost` interface and its docblock, and the function docblock and signature. ## Readers re-pointed (sums unchanged, no gate loosened) - `scripts/check-route-envelope.mjs` (a gate's target paths; see the note at the end): `approvals-routes.ts` is discovered by the `*-routes.ts` convention and declared `dialectOnly` with ratchet #9559, as the two earlier modules are. `rest-server.ts` stringError 18 to 17 and siblingCode 31 to 22; the new module carries 1 and 9, both measured by the gate. Sums 17 + 13 + 8 + 1 = 39 and 22 + 15 + 8 + 9 = 54, unchanged. The gate's output differs from base only in those lines and the module counts (13 to 14 modules, 4 to 5 ratcheted). - `packages/runtime/src/dispatcher-error-vocabulary.ts`, the declaration half of `check:dispatcher-error-vocabulary` (also a gate target path): the `runtime-pinned` row for the template family `APPROVAL_*_FAILED` names `approvals-routes.ts` where it named `rest-server.ts`. Re-pointed, not re-judged: 57 sites, 57 classified, as at base. The file is not reachable from `@objectstack/runtime`'s entry, so nothing published moves. - `docs/qa/platform-checklist/areas/approvals.json`: the two anchors `rest-server.ts#flowMoveRoute` and `rest-server.ts#handleApprovalError` name `approvals-routes.ts`, where both declarations now live. - `docs/qa/platform-checklist/areas/records-forms.json`: `rest-server.ts#mapping` resolved, at base, to the only `mapping` declaration in that file, which is `handleApprovalError`'s prefix map. It now names `approvals-routes.ts#mapping`, the same declaration. See the acceptance notes: that anchor's prose describes something the declaration never held. - `check:platform-checklist` output is identical base vs head for all three anchors (same per-family resolved counts, so no floor moves). - `packages/rest/src/execctx-consumer-census.test.ts`: reads the list; 62 sites = 41 + 9 + 5 + 7, unchanged. Mentions 85 to 89: the four new ones are this slice's seam (forwarder, two; interface member, two). Three forwarders now, one per moved module, pinned as before (`SITE_FILES.length - 1`); the stack-frame pattern is built from the list. - `packages/qa/dogfood/test/authz-probe-blind-spot.census.ts`: the `rest-server.ts` row's population rule reads the list, so 70 / 19 / 51 is unchanged; the seven moved registration sites are still `RestServer`'s and still in the blind spot. `this.routeManager.register(` stays 63 (42 + 9 + 5 + 7), with one control per module (the new one 7) and `registrar list length` 4. `enforceAuth` 68 to 72, re-anchored: four seam occurrences, no call site added. - `ui-view-route-identity.measurement.test.ts` and `ui-view-route-tenancy.measurement.test.ts`: `REGISTRAR_SOURCES` is the list. Over it `this.enforceAuth(` reads 50 and `this.resolveExecCtx(` 65 (49 and 64 at base, +1 each for the forwarder); `this.enforceEnvironmentOwnership(` stays 1. Both pin the list's length. - `rest-approvals-wire-codes.test.ts`: the header's present-tense pointer to the three route factories names `approvals-routes.ts`. **Red before the re-points**, each measured with the move in place and that reader not yet moved. The suites ran at `eaadafb41`, the move alone: the `rest` suite read `Tests 5 failed | 5840 passed | 326 skipped (6171)`, all five in `execctx-consumer-census.test.ts` (62 sites read 55, 46 bare read 39 twice, and the two driven sweeps 40 against at least 46 and 26 against at least 30); the dogfood pair read `Tests 2 failed | 106 passed (108)`, population 63 against 70 and the control `this.routeManager.register(` 56 against 63. - `check:route-envelope`: `approvals-routes.ts NOT DECLARED`, and `rest-server.ts` stringError 17 against 18, siblingCode 22 against 31. - `check:dispatcher-error-vocabulary`: `[stale-row] ... 'APPROVAL_*_FAILED' at packages/rest/src/rest-server.ts (objlittemplate) but the scan no longer finds it`, and `[unclassified-site] packages/rest/src/approvals-routes.ts`. - `check:platform-checklist`: 11 problems against base's 8, the three ABSENT SYMBOL anchors above. - ⚠️ The two `ui-view` readers would have stayed GREEN: their whole-file checks are floors (greater than 40), and without the list they read 43 and 58. That is the silent narrowing the list exists to close, and the reason they import it rather than staying as they were. ## Readers examined and not re-pointed - `auth-gate-allowlist-fault-window.measurement.test.ts`, `execctx-authz-input-seam-reachability.test.ts`, runtime `meta-list-projection-parity.test.ts`: they slice other members of `rest-server.ts` (the gate block, `computeExecCtx`, `/meta`). - core `authz-store-unavailable.test.ts`: it discovers transports by `resolveAuthzContext`, which the moved body does not call. - The ADR anchor `packages__rest__src__rest-server.ts.json` (ADR-0045, `filterAppForUser`) is unaffected. `docs/adr/0076-...md` names `registerApprovalsEndpoints` as an example; the private method is still the registrar, so the pointer still lands. - Gates with output identical base vs head (pre-merge reading at `60d82243b`): `check-durability-degradation-log-level`, `check-system-context-census`, `check-single-authz-resolver`, `check-rest-log-declared`, `check:liveness`, `check:cross-package-test-inputs`, `check:issue-citations`, `check:test-source-alias`, `check:rest-log-spy-declared`. Identical except the scanned-file counts (+2, the two new files): `check-meta-type-normalized`, `check-nul-bytes`, `check-adr-anchors`, `check:error-status-conformance`, `check:doc-authoring`, `check-undeclared-dep-imports` (+1 specifier). `check-scripts-symbol-anchors` +4 file-level anchors (the new comments in `check-route-envelope.mjs`); `check-affected-docs` 21 to 22 route sources (the new module, admitted by the filename convention). - No line citation in the repo points into the moved range (base :11869-:12348); the only `rest-server.ts:NNNNN` citation at or above :11000 is `:11108`, in a release-owned CHANGELOG. ## Acceptance notes - **`packages/spec` prose, not edited (the claim keeps `packages/spec` out):** `packages/spec/src/api/error-code-ledger.zod.ts` names `rest-server.ts` as the home of the approvals route factories and `handleApprovalError` in three comments (about :199, :291 and :1274). They are `//` comments, read by no gate. They now describe code in `approvals-routes.ts`. Named in the report for the `domain:spec` seat. - `docs/qa/platform-checklist/areas/records-forms.json`'s `#mapping` anchor (the OCC item, "CONCURRENT_UPDATE → 409 mapping") lands, at base and at head, on `handleApprovalError`'s prefix map, which holds no `CONCURRENT_UPDATE` row; `packages/rest/src` spells `CONCURRENT_UPDATE` only in one comment. The move keeps it landing where it landed. Re-judging which declaration that item should cite is not a move. - `packages/plugins/plugin-approvals/src/record-reader-visibility.test.ts` (about :363) cites "`rest-server.ts` handleApprovalError" in a comment. Another lane's file, read by nothing; not edited. - `scripts/check-self-test-wired.mjs`'s header quotes a past `[stale-row]` message that names `rest-server.ts`. It is a quotation of output, not a pointer; not edited. - `rest-approvals-wire-codes.test.ts` keeps one dated measurement sentence ("deleting the row from `rest-server.ts` reds THREE cases"), true when written; only its present-tense pointer was re-pointed. - `docs/adr/0076-...md` D11 records the maintainer's 2026-08-15 ruling that `rest-server.ts` would be delineated, not split; the card's later direction (quoted on #23050) is what slices 1 to 3 execute. The ADR is a governed surface and is not edited here. - Lines of `rest-server.ts` from about :42 to :318 shift by -3, from :322 to the moved block by -1, and after it by -450 (base :12351 is head :11901). ## Gates and tests Measured at `9305039c6`: this branch (base `c7dd48ab4`) plus one merge of `origin/main` `1ced0f82b`, which touched no file in this diff (cli, metadata-core, pm scripts and four other checklist areas). All builds and tests went through `os-verify-lock`; exit codes were captured to files before any pipe. - `pnpm --filter @objectstack/rest test` (project `local`): Test Files 285 passed (285), Tests 5852 passed / 326 skipped (6178). Base `c7dd48ab4`: 5845 passed / 326 skipped (6171); the +7 are this PR's new cases (query-number, meta-draft and docblock list pins, plus the docblock pin's two modules that are new to it). - `pnpm --filter @objectstack/rest test:repo` (project `repo`): Test Files 5 passed (5), Tests 222 passed / 4 skipped (226). Base: 213 passed / 2 skipped (215); the +9 / +2 are the canonical-query-AST census's two new modules and its list control. - `pnpm --filter @objectstack/rest typecheck`: exit 0 (`tsc --noEmit`, then `check:test-typecheck`: 0 file(s) / 0 error(s)). - `pnpm --filter @objectstack/dogfood exec vitest run test/authz-probe-blind-spot.test.ts test/authz-conformance.test.ts`: Test Files 2 passed (2), Tests 108 passed (108). - `pnpm --filter @objectstack/runtime exec vitest run src/error-envelope.conformance.test.ts` (the test that imports the edited ledger): 53 passed. `pnpm --filter @objectstack/runtime typecheck`: exit 0. - NOT MEASURED: `pnpm --filter @objectstack/dogfood typecheck`, reason: exit 2 on 52 errors, 51 of them TS2307 for workspace packages with no `dist` in this container (`connector-mcp`, `connector-rest`, `connector-openapi`, `cloud-connection`, `setup`, `connector-slack`, `account`) and one TS7006 behind them in an untouched file; none in either touched dogfood file. CI's typecheck builds first. - Lint: `eslint --no-inline-config --format json` over `packages/rest`, the two touched dogfood files, `packages/runtime/src/dispatcher-error-vocabulary.ts` and `scripts/check-route-envelope.mjs`: 327 files, 0 errors, 0 warnings, every touched lintable file in the result set. Repo-wide `pnpm lint` is CI's. - `dispatch-gates --commands --repo objectstack-ai/objectstack`, no paths, after the last commit: 80 commands (stderr names commit `9305039c6`). `--ran`: `Run reconciliation — 80 derived, 79 run, 1 NOT-MEASURED, 0 UNRUN.` 79 exit 0. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET (exit 3; workspace packages without `dist/` in this container). Stand-in: `packages/rest/dist/index.cjs` and `index.js` both load, 39 exports each. CI runs the gate over a full build. - `pnpm check:route-envelope` and `pnpm check:durability-log-level` are in the derivation and both exit 0. `pnpm check:platform-checklist` exits 0 at this head (`origin/main` brought the fix for its eight pre-existing findings); before the merge its output was identical to base's. - `check:dev-prereqs` and `check:skill-frame-freshness` (local-only) were not run: no `packages/spec` build input and no four-axis frame file is touched. Per command (exit code): - `node scripts/check-ci-filter-parity.mjs` exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` exit 0 - `node scripts/check-closing-keyword-parity.mjs` exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` exit 0 - `node scripts/check-comment-mask-adoption.mjs` exit 0 - `node scripts/check-comment-mask-corpus.mjs` exit 0 - `node scripts/check-declaration-mirrors.mjs --self-test` exit 0 - `node scripts/check-declaration-mirrors.mjs` exit 0 - `node scripts/check-dts-emitted.mjs --self-test` exit 0 - `node scripts/check-issue-citations.mjs` exit 0 - `node scripts/check-keyed-text-bounds.mjs --self-test` exit 0 - `node scripts/check-keyed-text-bounds.mjs` exit 0 - `node scripts/check-platform-object-tenancy-census.mjs --self-test` exit 0 - `node scripts/check-platform-object-tenancy-census.mjs` exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 0 - `node scripts/check-plugin-teardown-shape.mjs` exit 0 - `node scripts/check-registry-log-declared.mjs --self-test` exit 0 - `node scripts/check-registry-log-declared.mjs` exit 0 - `node scripts/check-rest-log-spy-declared.mjs --self-test` exit 0 - `node scripts/check-rest-log-spy-declared.mjs` exit 0 - `node scripts/check-scripts-symbol-anchors.mjs --self-test` exit 0 - `node scripts/check-scripts-symbol-anchors.mjs` exit 0 - `node scripts/check-self-test-wired.mjs --self-test` exit 0 - `node scripts/check-self-test-wired.mjs` exit 0 - `node scripts/check-self-test-workflow-commands.mjs --self-test` exit 0 - `node scripts/check-self-test-workflow-commands.mjs` exit 0 - `node scripts/check-system-context-census.mjs --self-test` exit 0 - `node scripts/check-system-context-census.mjs` exit 0 - `node scripts/check-undeclared-dep-imports.mjs --self-test` exit 0 - `node scripts/check-undeclared-dep-imports.mjs` exit 0 - `node scripts/check-whole-set-label-write.mjs --self-test` exit 0 - `node scripts/check-whole-set-label-write.mjs` exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` exit 0 - `node scripts/pm/bare-root-worklist.mjs --self-test` exit 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` exit 0 - `pnpm --filter @objectstack/spec run check:empty-state` exit 0 - `pnpm --filter @objectstack/spec run check:liveness` exit 0 - `pnpm --filter @objectstack/spec run check:strictness-ledger` exit 0 - `pnpm --filter @objectstack/spec run check:variant-docs` exit 0 - `pnpm check:agent-test-spelling` exit 0 - `pnpm check:authz-resolver` exit 0 - `pnpm check:bash32-floor` exit 0 - `pnpm check:cli-command-ids` exit 0 - `pnpm check:cross-package-test-inputs` exit 0 - `pnpm check:dispatcher-error-vocabulary` exit 0 - `pnpm check:doc-authoring` exit 0 - `pnpm check:driver-memory-census` exit 0 - `pnpm check:dts-closure` exit 0 - `pnpm check:dual-build-cjs-loads` exit 3 - `pnpm check:durability-log-level` exit 0 - `pnpm check:engine-double-contract` exit 0 - `pnpm check:entry-guard` exit 0 - `pnpm check:error-status-conformance` exit 0 - `pnpm check:gitlink-declared` exit 0 - `pnpm check:issue-citations` exit 0 - `pnpm check:lean-entry-closure` exit 0 - `pnpm check:logger-receiver-detach` exit 0 - `pnpm check:nul-bytes` exit 0 - `pnpm check:objectql-double-limit` exit 0 - `pnpm check:org-identifier` exit 0 - `pnpm check:page-declaration-shape` exit 0 - `pnpm check:parse-guard` exit 0 - `pnpm check:platform-checklist` exit 0 - `pnpm check:pm-dispatch-gates` exit 0 - `pnpm check:pnpm-filter-targets` exit 0 - `pnpm check:published-files` exit 0 - `pnpm check:query-options-erasure` exit 0 - `pnpm check:ratchet-remedy-authority` exit 0 - `pnpm check:refd-timer-probe` exit 0 - `pnpm check:route-envelope` exit 0 - `pnpm check:slot-lookup` exit 0 - `pnpm check:sourcemap-no-sources-content` exit 0 - `pnpm check:test-source-alias` exit 0 - `pnpm check:tier-file-adoption` exit 0 - `pnpm check:type-check-coverage` exit 0 - `pnpm check:type-check-debt` exit 0 - `pnpm check:watch-hint-literal` exit 0 - `pnpm check:where-matcher` exit 0 **Changeset:** none. `@objectstack/rest` ships `files: ["dist", "README.md", "CHANGELOG.md"]`. Its `dist/index.d.ts` and `index.d.cts` are byte-identical, its export list is identical, and its route table is identical under three configs; the bundle JS differs only by where the code sits. `@objectstack/runtime`'s one edit is in a module its entry does not reach (rebuilt at `9305039c6`: `packages/runtime/dist` holds no file containing `APPROVAL_*_FAILED`, `UNREGISTERED_CODE_SITES` or `approvals-routes.ts`, while the control `HttpDispatcher` is found in 4 files). The other files are tests, a private package, a script and QA data. `skip-changeset` is the seat's to apply on its own check. **Contract review:** this PR edits two gate target paths: `scripts/check-route-envelope.mjs` (one new `MODULES` entry and two lowered `rest-server.ts` counts) and the `file` of one row in `packages/runtime/src/dispatcher-error-vocabulary.ts`, the declaration half of `check:dispatcher-error-vocabulary`. --- _Generated by [Claude Code](https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e6e4e1 commit 8d1fb1f

17 files changed

Lines changed: 901 additions & 524 deletions

‎docs/qa/platform-checklist/areas/approvals.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,12 +508,12 @@
508508
"source": [
509509
"#3358 §1 (the action-table evidence this matrix grounds in)",
510510
"packages/rest/src/rest-route-ledger.ts (approve/reject/recall/revise/resubmit/reassign/remind/request-info/comment + GET /:id/actions)",
511-
"packages/rest/src/rest-server.ts#flowMoveRoute (flowMoveRoute: revise=pending approver, resubmit=submitter; threadRoute access per action; recall submitter-only)",
511+
"packages/rest/src/approvals-routes.ts#flowMoveRoute (flowMoveRoute: revise=pending approver, resubmit=submitter; threadRoute access per action; recall submitter-only)",
512512
"packages/plugins/plugin-approvals/src/approval-service.ts#ApprovalService (ApprovalService.resubmit — 'traversal walks the declared back-edge into the approval node, whose executor opens the round-N+1 request'; the resubmitted row's status is never rewritten. sendBack counts prior rounds as returned siblings on flow_run_id + flow_node_id)",
513513
"packages/spec/src/contracts/approval-service.ts#APPROVAL_STATUSES (APPROVAL_STATUSES: pending|approved|rejected|recalled|returned)",
514514
"examples/app-showcase/src/automation/flows/index.ts#BudgetApprovalFlow (BudgetApprovalFlow — ADR-0044 revise loop, maxRevisions 2, exec step without a revise edge, lockRecord pair)",
515515
"packages/plugins/plugin-approvals/src/approval-service.ts#readBackRequest (readBackRequest — the write path's post-mutation echo; #12769 docblock: org-less rows are a live state, the null echo used to escape behind the callers' fresh! non-null assertions, now it refuses loudly; landed as PR #13181)",
516-
"packages/rest/src/rest-server.ts#handleApprovalError (handleApprovalError prefix map — READ_BACK_FAILED deliberately unmapped, so it reaches the wire as the generic 500 APPROVAL_<action>_FAILED envelope carrying the named text)"
516+
"packages/rest/src/approvals-routes.ts#handleApprovalError (handleApprovalError prefix map — READ_BACK_FAILED deliberately unmapped, so it reaches the wire as the generic 500 APPROVAL_<action>_FAILED envelope carrying the named text)"
517517
],
518518
"history": [
519519
{ "revision": 1, "date": "2026-08-07", "change": "initial — decision-action matrix derived from the approvals REST route ledger and the ADR-0044 revise/resubmit flow shape", "ref": "claude/platform-test-checklist-ocwugl" },

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2414,7 +2414,7 @@
24142414
"source": [
24152415
"objectui: packages/plugin-detail/src/ConcurrentUpdateDialog.tsx (Reload/Overwrite UX), packages/plugin-form/src/occSave.tsx (If-Match → 409, conflict dialog, re-key overwrite)",
24162416
"objectui: packages/plugin-detail/src/InlineEditSaveBar.tsx + packages/react/src/context/InlineEditContext.tsx (one save bar, changed-keys draft, ifMatch)",
2417-
"framework: packages/rest/src/rest-server.ts#mapping (CONCURRENT_UPDATE → 409 mapping), packages/metadata-protocol/src/protocol.ts#updateData updateData (If-Match / expectedVersion)",
2417+
"framework: packages/rest/src/approvals-routes.ts#mapping (CONCURRENT_UPDATE → 409 mapping), packages/metadata-protocol/src/protocol.ts#updateData updateData (If-Match / expectedVersion)",
24182418
"PENDING-GAPS §C concurrent-edit-conflict + §E1 inline-edit atomic two-surface (objectui#2542/2549/2604); cross-ref records-forms.crud-roundtrip"
24192419
],
24202420
"history": [

‎packages/qa/dogfood/test/authz-conformance.test.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,25 @@ import {
3535
LEDGER_POPULATION_BASELINE,
3636
LEDGER_POPULATION_BASELINE_MAX,
3737
} from './authz-ledger-population.baseline.js';
38+
// [#23050 slice 3] THE list of the files `RestServer`'s registrars live in —
39+
// see the two `rest-server.ts` realtime tripwires below.
40+
import {
41+
REST_SERVER_REGISTRAR_DIR,
42+
REST_SERVER_REGISTRAR_FILES,
43+
} from '../../../rest/src/rest-server-registrar-files.js';
3844

3945
const HERE = dirname(fileURLToPath(import.meta.url));
4046
// packages/qa/dogfood/test → repo root.
4147
const REPO_ROOT = join(HERE, '../../../..');
4248

49+
// [#23050 slice 3] Every file on the registrar list, repo-relative, and the ones
50+
// beyond `rest-server.ts` that the realtime tripwires read WITH it.
51+
const REST_SERVER_FILE = 'packages/rest/src/rest-server.ts';
52+
const REST_SERVER_REGISTRAR_PATHS: readonly string[] =
53+
REST_SERVER_REGISTRAR_FILES.map((file) => `${REST_SERVER_REGISTRAR_DIR}/${file}`);
54+
const REST_SERVER_REGISTRAR_ALSO_READS: readonly string[] =
55+
REST_SERVER_REGISTRAR_PATHS.filter((file) => file !== REST_SERVER_FILE);
56+
4357
// ── #7976 — mutual row ↔ proof attribution ────────────────────────────────
4458
// A proof file self-declares the rows it is the proof FOR with header
4559
// `// authz-row: <id>` lines, and `checkLedger` asserts the pairing both ways.
@@ -123,6 +137,17 @@ interface Probe {
123137
* reading OF the table rather than of the file that carries it.
124138
*/
125139
within?: string;
140+
/**
141+
* [#23050 slice 3] More files read WITH `file`, as one text. Only the two
142+
* `rest-server.ts` realtime tripwires declare it: they watch the code
143+
* `RestServer`'s registrars run, and the split of `rest-server.ts` moves
144+
* registrar bodies into modules of their own, so they read every file on the
145+
* registrar list (`packages/rest/src/rest-server-registrar-files.ts`,
146+
* imported above) and a transport wired in a moved body still trips them.
147+
* `file` stays the one file the entry NAMES — what the probe-table census and
148+
* the matrix header count — and the minted key is unchanged.
149+
*/
150+
alsoReads?: readonly string[];
126151
key: (m: RegExpExecArray) => string;
127152
}
128153

@@ -430,12 +455,14 @@ const PROBES: readonly Probe[] = [
430455
{
431456
kind: 'TRIPWIRE',
432457
file: 'packages/rest/src/rest-server.ts',
458+
alsoReads: REST_SERVER_REGISTRAR_ALSO_READS,
433459
re: /handleUpgrade\s*\(|new\s+WebSocketServer\b|new\s+WebSocket\b|new\s+EventSource\b|upgradeWebSocket\b|WebSocketPair\b|Sec-WebSocket-|text\/event-stream|streamSSE\s*\(/g,
434460
key: () => tripwireKey('realtime:rest-server.ts:transport'),
435461
},
436462
{
437463
kind: 'TRIPWIRE',
438464
file: 'packages/rest/src/rest-server.ts',
465+
alsoReads: REST_SERVER_REGISTRAR_ALSO_READS,
439466
// Word-boundary lookaheads on `ws`/`stream` keep the file's 31 unrelated
440467
// response-streaming references (and paths like `/workspaces`) out: they
441468
// are matched only as a whole route segment, never as a prefix.
@@ -520,7 +547,10 @@ const PROBES: readonly Probe[] = [
520547
*/
521548
function probeSource(probe: Probe): string {
522549
const src = readFileSync(join(REPO_ROOT, probe.file), 'utf8');
523-
return probe.within ? scopedSource(src, probe.within) : src;
550+
if (probe.within) return scopedSource(src, probe.within);
551+
// [#23050 slice 3] `alsoReads` widens the read, never the scope: no probe
552+
// declares both, and `within` above returns before it.
553+
return [src, ...(probe.alsoReads ?? []).map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'))].join('\n');
524554
}
525555

526556
/**
@@ -1591,6 +1621,18 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', (
15911621
const src = readFileSync(join(REPO_ROOT, REST_SERVER), 'utf8');
15921622
expect(hits(MECHANICS.re, src)).toEqual([]);
15931623
expect(hits(ROUTES.re, src)).toEqual([]);
1624+
// [#23050 slice 3] …and on every file `RestServer`'s registrars live in:
1625+
// 0 over the list, whose length is pinned so a module added to it makes this
1626+
// re-read. The control is that both probes really READ the list — their
1627+
// text is every file on it, in order — so the zero is not a list ignored.
1628+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4);
1629+
expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER);
1630+
const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'));
1631+
for (const probe of [MECHANICS, ROUTES]) {
1632+
expect(probe.alsoReads).toEqual(REST_SERVER_REGISTRAR_PATHS.slice(1));
1633+
expect(probeSource(probe)).toBe(listed.join('\n'));
1634+
}
1635+
expect(listed.reduce((n, text) => n + hits(MECHANICS.re, text).length + hits(ROUTES.re, text).length, 0)).toBe(0);
15941636
// …and the surface walk agrees: no rest-server tripwire key is minted.
15951637
const discovered = [...discoverAnonymousDenySurfaces()].filter((k) =>
15961638
k.startsWith('realtime:rest-server.ts:'),

‎packages/qa/dogfood/test/authz-probe-blind-spot.census.ts‎

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,12 @@
238238
import { readFileSync } from 'node:fs';
239239
import { fileURLToPath } from 'node:url';
240240
import { dirname, join } from 'node:path';
241+
// [#23050 slice 3] THE list of the files `RestServer`'s registrars live in —
242+
// see the `rest-server.ts` row's population rule.
243+
import {
244+
REST_SERVER_REGISTRAR_DIR,
245+
REST_SERVER_REGISTRAR_FILES,
246+
} from '../../../rest/src/rest-server-registrar-files.js';
241247

242248
const HERE = dirname(fileURLToPath(import.meta.url));
243249
// packages/qa/dogfood/test → repo root.
@@ -511,9 +517,10 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
511517
'route registration sites — `this.routeManager.register(` call sites, LESS the one inside ' +
512518
'`registerPerItemRoute` (the shared forwarder, not a route; its extent is bounded by the declaration\'s own ' +
513519
'indentation and the subtrahend is pinned at 1 by the `forwarder slice:` control, never inferred from a ' +
514-
'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND ' +
515-
'`data-action-routes.ts` and `batch-routes.ts`, the modules the bodies of `registerDataActionEndpoints` ' +
516-
'and `registerBatchEndpoints` moved to; ' +
520+
'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND every ' +
521+
'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' +
522+
'`data-action-routes.ts`, `batch-routes.ts` and `approvals-routes.ts`, the modules the bodies of ' +
523+
'`registerDataActionEndpoints`, `registerBatchEndpoints` and `registerApprovalsEndpoints` moved to; ' +
517524
'reachable = those inside registerMetadataEndpoints',
518525
// [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and
519526
// the numbers it produces did NOT move: 80 / 19 / 61, exactly as before.
@@ -657,19 +664,34 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
657664
// are counted in both readings; the four new occurrences are that slice's
658665
// seam, the same two spellings as slice 1's (the host literal's forwarder,
659666
// two; the host interface's member, two). No call site was added.
667+
// [#23050 slice 3] 70 / 19 / 51 UNCHANGED, the same measurement again:
668+
// `registerApprovalsEndpoints`' body (seven registration sites, all outside
669+
// `registerMetadataEndpoints`, so all in the blind spot) moved to
670+
// `approvals-routes.ts`. The population rule now reads the registrar LIST
671+
// rather than naming each module, so the slice that moves the next body
672+
// widens it by adding its module there; each module keeps its own control
673+
// and the list's length is one too. `this.routeManager.register(` stays 63
674+
// over the four files (42 here + 9 + 5 + 7), `private register*Endpoints(`
675+
// stays 16, and `enforceAuth` 68 -> 72 is RE-ANCHORED, not relaxed: the
676+
// seven call sites moved with their routes and are counted in both
677+
// readings; the four new occurrences are this slice's seam, the same two
678+
// spellings (the host literal's forwarder, two; the host interface's
679+
// member, two). No call site was added.
660680
controls: {
661681
'private register*Endpoints(': 16,
662682
'this.routeManager.register(': 63,
683+
'registrar list length': 4,
663684
'data-action-routes.ts: this.routeManager.register(': 9,
664685
'batch-routes.ts: this.routeManager.register(': 5,
686+
'approvals-routes.ts: this.routeManager.register(': 7,
665687
// Both halves of the new rule carry their own control, so neither can go
666688
// silently to zero: a helper deleted and its routes inlined back would
667689
// still read population 80, and only these two controls would notice the
668690
// shape moved and force this provenance to be re-read.
669691
'registerPerItemRoute(': 8,
670692
'const registerPerItemRoute =': 1,
671693
'forwarder slice: this.routeManager.register(': 1,
672-
enforceAuth: 68,
694+
enforceAuth: 72,
673695
},
674696
note:
675697
'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' +
@@ -1045,13 +1067,22 @@ export function deriveProbeFileCensus(): {
10451067
// no route. The moved module defines no registrar (`private register…(`) and
10461068
// no `registerPerItemRoute`, so appending it moves neither the forwarder
10471069
// slice nor the `reachable` slice; its own control below proves it is read.
1048-
const moved = read('packages/rest/src/data-action-routes.ts');
1070+
//
10491071
// [#23050 slice 2] The same lesson, again: `registerBatchEndpoints`' body
10501072
// moved to `batch-routes.ts` with its five registration sites. Appended for
10511073
// the same reason and on the same terms — it declares no registrar and no
10521074
// `registerPerItemRoute` either — with its own control below.
1053-
const movedBatch = read('packages/rest/src/batch-routes.ts');
1054-
const all = `${src}\n${moved}\n${movedBatch}`;
1075+
//
1076+
// [#23050 slice 3] …and `registerApprovalsEndpoints`' body to
1077+
// `approvals-routes.ts`, with its seven. The modules are no longer named
1078+
// here one by one: they are read off THE registrar list, in its order, so
1079+
// the slice that moves the next body adds its module there once. Every
1080+
// module on it is appended on the same terms and gets its own control
1081+
// below, and the list's length is a control too.
1082+
const movedModules = REST_SERVER_REGISTRAR_FILES
1083+
.filter((file) => file !== 'rest-server.ts')
1084+
.map((file) => [file, read(`${REST_SERVER_REGISTRAR_DIR}/${file}`)] as const);
1085+
const all = [src, ...movedModules.map(([, text]) => text)].join('\n');
10551086
const registrarRe = /^\s*private\s+register[A-Za-z]*Endpoints\s*\(/gm;
10561087
const mountRe = /this\.routeManager\.register\(/g;
10571088
// Matches the CALL sites only. The declaration reads
@@ -1152,10 +1183,14 @@ export function deriveProbeFileCensus(): {
11521183
controls: {
11531184
'private register*Endpoints(': occurrences(src, /private\s+register[A-Za-z]*Endpoints\s*\(/g),
11541185
'this.routeManager.register(': occurrences(all, /this\.routeManager\.register\(/g),
1186+
// [#23050 slice 3] The registrar list's length, so a module added to it
1187+
// makes this row re-read its numbers.
1188+
'registrar list length': REST_SERVER_REGISTRAR_FILES.length,
11551189
// [#23050 slice 1] The moved module's own share, so a reading that
11561190
// silently stopped appending it cannot hide behind the total above.
1157-
'data-action-routes.ts: this.routeManager.register(': occurrences(moved, /this\.routeManager\.register\(/g),
1158-
'batch-routes.ts: this.routeManager.register(': occurrences(movedBatch, /this\.routeManager\.register\(/g),
1191+
// [#23050 slice 3] One such control per module on the list.
1192+
...Object.fromEntries(movedModules.map(([file, text]) =>
1193+
[`${file}: this.routeManager.register(`, occurrences(text, /this\.routeManager\.register\(/g)])),
11591194
'registerPerItemRoute(': occurrences(src, /registerPerItemRoute\(/g),
11601195
'const registerPerItemRoute =': occurrences(src, /const\s+registerPerItemRoute\s*=/g),
11611196
// ⭐ The one control here that is NOT a whole-file count, deliberately:

0 commit comments

Comments
 (0)