Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/qa/platform-checklist/areas/approvals.json
Original file line number Diff line number Diff line change
Expand Up @@ -508,12 +508,12 @@
"source": [
"#3358 §1 (the action-table evidence this matrix grounds in)",
"packages/rest/src/rest-route-ledger.ts (approve/reject/recall/revise/resubmit/reassign/remind/request-info/comment + GET /:id/actions)",
"packages/rest/src/rest-server.ts#flowMoveRoute (flowMoveRoute: revise=pending approver, resubmit=submitter; threadRoute access per action; recall submitter-only)",
"packages/rest/src/approvals-routes.ts#flowMoveRoute (flowMoveRoute: revise=pending approver, resubmit=submitter; threadRoute access per action; recall submitter-only)",
"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)",
"packages/spec/src/contracts/approval-service.ts#APPROVAL_STATUSES (APPROVAL_STATUSES: pending|approved|rejected|recalled|returned)",
"examples/app-showcase/src/automation/flows/index.ts#BudgetApprovalFlow (BudgetApprovalFlow — ADR-0044 revise loop, maxRevisions 2, exec step without a revise edge, lockRecord pair)",
"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)",
"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)"
"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)"
],
"history": [
{ "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" },
Expand Down
2 changes: 1 addition & 1 deletion docs/qa/platform-checklist/areas/records-forms.json
Original file line number Diff line number Diff line change
Expand Up @@ -2414,7 +2414,7 @@
"source": [
"objectui: packages/plugin-detail/src/ConcurrentUpdateDialog.tsx (Reload/Overwrite UX), packages/plugin-form/src/occSave.tsx (If-Match → 409, conflict dialog, re-key overwrite)",
"objectui: packages/plugin-detail/src/InlineEditSaveBar.tsx + packages/react/src/context/InlineEditContext.tsx (one save bar, changed-keys draft, ifMatch)",
"framework: packages/rest/src/rest-server.ts#mapping (CONCURRENT_UPDATE → 409 mapping), packages/metadata-protocol/src/protocol.ts#updateData updateData (If-Match / expectedVersion)",
"framework: packages/rest/src/approvals-routes.ts#mapping (CONCURRENT_UPDATE → 409 mapping), packages/metadata-protocol/src/protocol.ts#updateData updateData (If-Match / expectedVersion)",
"PENDING-GAPS §C concurrent-edit-conflict + §E1 inline-edit atomic two-surface (objectui#2542/2549/2604); cross-ref records-forms.crud-roundtrip"
],
"history": [
Expand Down
44 changes: 43 additions & 1 deletion packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,25 @@ import {
LEDGER_POPULATION_BASELINE,
LEDGER_POPULATION_BASELINE_MAX,
} from './authz-ledger-population.baseline.js';
// [#23050 slice 3] THE list of the files `RestServer`'s registrars live in —
// see the two `rest-server.ts` realtime tripwires below.
import {
REST_SERVER_REGISTRAR_DIR,
REST_SERVER_REGISTRAR_FILES,
} from '../../../rest/src/rest-server-registrar-files.js';

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

// [#23050 slice 3] Every file on the registrar list, repo-relative, and the ones
// beyond `rest-server.ts` that the realtime tripwires read WITH it.
const REST_SERVER_FILE = 'packages/rest/src/rest-server.ts';
const REST_SERVER_REGISTRAR_PATHS: readonly string[] =
REST_SERVER_REGISTRAR_FILES.map((file) => `${REST_SERVER_REGISTRAR_DIR}/${file}`);
const REST_SERVER_REGISTRAR_ALSO_READS: readonly string[] =
REST_SERVER_REGISTRAR_PATHS.filter((file) => file !== REST_SERVER_FILE);

// ── #7976 — mutual row ↔ proof attribution ────────────────────────────────
// A proof file self-declares the rows it is the proof FOR with header
// `// authz-row: <id>` lines, and `checkLedger` asserts the pairing both ways.
Expand Down Expand Up @@ -123,6 +137,17 @@ interface Probe {
* reading OF the table rather than of the file that carries it.
*/
within?: string;
/**
* [#23050 slice 3] More files read WITH `file`, as one text. Only the two
* `rest-server.ts` realtime tripwires declare it: they watch the code
* `RestServer`'s registrars run, and the split of `rest-server.ts` moves
* registrar bodies into modules of their own, so they read every file on the
* registrar list (`packages/rest/src/rest-server-registrar-files.ts`,
* imported above) and a transport wired in a moved body still trips them.
* `file` stays the one file the entry NAMES — what the probe-table census and
* the matrix header count — and the minted key is unchanged.
*/
alsoReads?: readonly string[];
key: (m: RegExpExecArray) => string;
}

Expand Down Expand Up @@ -430,12 +455,14 @@ const PROBES: readonly Probe[] = [
{
kind: 'TRIPWIRE',
file: 'packages/rest/src/rest-server.ts',
alsoReads: REST_SERVER_REGISTRAR_ALSO_READS,
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,
key: () => tripwireKey('realtime:rest-server.ts:transport'),
},
{
kind: 'TRIPWIRE',
file: 'packages/rest/src/rest-server.ts',
alsoReads: REST_SERVER_REGISTRAR_ALSO_READS,
// Word-boundary lookaheads on `ws`/`stream` keep the file's 31 unrelated
// response-streaming references (and paths like `/workspaces`) out: they
// are matched only as a whole route segment, never as a prefix.
Expand Down Expand Up @@ -520,7 +547,10 @@ const PROBES: readonly Probe[] = [
*/
function probeSource(probe: Probe): string {
const src = readFileSync(join(REPO_ROOT, probe.file), 'utf8');
return probe.within ? scopedSource(src, probe.within) : src;
if (probe.within) return scopedSource(src, probe.within);
// [#23050 slice 3] `alsoReads` widens the read, never the scope: no probe
// declares both, and `within` above returns before it.
return [src, ...(probe.alsoReads ?? []).map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'))].join('\n');
}

/**
Expand Down Expand Up @@ -1591,6 +1621,18 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', (
const src = readFileSync(join(REPO_ROOT, REST_SERVER), 'utf8');
expect(hits(MECHANICS.re, src)).toEqual([]);
expect(hits(ROUTES.re, src)).toEqual([]);
// [#23050 slice 3] …and on every file `RestServer`'s registrars live in:
// 0 over the list, whose length is pinned so a module added to it makes this
// re-read. The control is that both probes really READ the list — their
// text is every file on it, in order — so the zero is not a list ignored.
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4);
expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER);
const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'));
for (const probe of [MECHANICS, ROUTES]) {
expect(probe.alsoReads).toEqual(REST_SERVER_REGISTRAR_PATHS.slice(1));
expect(probeSource(probe)).toBe(listed.join('\n'));
}
expect(listed.reduce((n, text) => n + hits(MECHANICS.re, text).length + hits(ROUTES.re, text).length, 0)).toBe(0);
// …and the surface walk agrees: no rest-server tripwire key is minted.
const discovered = [...discoverAnonymousDenySurfaces()].filter((k) =>
k.startsWith('realtime:rest-server.ts:'),
Expand Down
53 changes: 44 additions & 9 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
// [#23050 slice 3] THE list of the files `RestServer`'s registrars live in —
// see the `rest-server.ts` row's population rule.
import {
REST_SERVER_REGISTRAR_DIR,
REST_SERVER_REGISTRAR_FILES,
} from '../../../rest/src/rest-server-registrar-files.js';

const HERE = dirname(fileURLToPath(import.meta.url));
// packages/qa/dogfood/test → repo root.
Expand Down Expand Up @@ -511,9 +517,10 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
'route registration sites — `this.routeManager.register(` call sites, LESS the one inside ' +
'`registerPerItemRoute` (the shared forwarder, not a route; its extent is bounded by the declaration\'s own ' +
'indentation and the subtrahend is pinned at 1 by the `forwarder slice:` control, never inferred from a ' +
'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND ' +
'`data-action-routes.ts` and `batch-routes.ts`, the modules the bodies of `registerDataActionEndpoints` ' +
'and `registerBatchEndpoints` moved to; ' +
'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND every ' +
'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' +
'`data-action-routes.ts`, `batch-routes.ts` and `approvals-routes.ts`, the modules the bodies of ' +
'`registerDataActionEndpoints`, `registerBatchEndpoints` and `registerApprovalsEndpoints` moved to; ' +
'reachable = those inside registerMetadataEndpoints',
// [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and
// the numbers it produces did NOT move: 80 / 19 / 61, exactly as before.
Expand Down Expand Up @@ -657,19 +664,34 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
// are counted in both readings; the four new occurrences are that slice's
// seam, the same two spellings as slice 1's (the host literal's forwarder,
// two; the host interface's member, two). No call site was added.
// [#23050 slice 3] 70 / 19 / 51 UNCHANGED, the same measurement again:
// `registerApprovalsEndpoints`' body (seven registration sites, all outside
// `registerMetadataEndpoints`, so all in the blind spot) moved to
// `approvals-routes.ts`. The population rule now reads the registrar LIST
// rather than naming each module, so the slice that moves the next body
// widens it by adding its module there; each module keeps its own control
// and the list's length is one too. `this.routeManager.register(` stays 63
// over the four files (42 here + 9 + 5 + 7), `private register*Endpoints(`
// stays 16, and `enforceAuth` 68 -> 72 is RE-ANCHORED, not relaxed: the
// seven call sites moved with their routes and are counted in both
// readings; the four new occurrences are this slice's seam, the same two
// spellings (the host literal's forwarder, two; the host interface's
// member, two). No call site was added.
controls: {
'private register*Endpoints(': 16,
'this.routeManager.register(': 63,
'registrar list length': 4,
'data-action-routes.ts: this.routeManager.register(': 9,
'batch-routes.ts: this.routeManager.register(': 5,
'approvals-routes.ts: this.routeManager.register(': 7,
// Both halves of the new rule carry their own control, so neither can go
// silently to zero: a helper deleted and its routes inlined back would
// still read population 80, and only these two controls would notice the
// shape moved and force this provenance to be re-read.
'registerPerItemRoute(': 8,
'const registerPerItemRoute =': 1,
'forwarder slice: this.routeManager.register(': 1,
enforceAuth: 68,
enforceAuth: 72,
},
note:
'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' +
Expand Down Expand Up @@ -1045,13 +1067,22 @@ export function deriveProbeFileCensus(): {
// no route. The moved module defines no registrar (`private register…(`) and
// no `registerPerItemRoute`, so appending it moves neither the forwarder
// slice nor the `reachable` slice; its own control below proves it is read.
const moved = read('packages/rest/src/data-action-routes.ts');
//
// [#23050 slice 2] The same lesson, again: `registerBatchEndpoints`' body
// moved to `batch-routes.ts` with its five registration sites. Appended for
// the same reason and on the same terms — it declares no registrar and no
// `registerPerItemRoute` either — with its own control below.
const movedBatch = read('packages/rest/src/batch-routes.ts');
const all = `${src}\n${moved}\n${movedBatch}`;
//
// [#23050 slice 3] …and `registerApprovalsEndpoints`' body to
// `approvals-routes.ts`, with its seven. The modules are no longer named
// here one by one: they are read off THE registrar list, in its order, so
// the slice that moves the next body adds its module there once. Every
// module on it is appended on the same terms and gets its own control
// below, and the list's length is a control too.
const movedModules = REST_SERVER_REGISTRAR_FILES
.filter((file) => file !== 'rest-server.ts')
.map((file) => [file, read(`${REST_SERVER_REGISTRAR_DIR}/${file}`)] as const);
const all = [src, ...movedModules.map(([, text]) => text)].join('\n');
const registrarRe = /^\s*private\s+register[A-Za-z]*Endpoints\s*\(/gm;
const mountRe = /this\.routeManager\.register\(/g;
// Matches the CALL sites only. The declaration reads
Expand Down Expand Up @@ -1152,10 +1183,14 @@ export function deriveProbeFileCensus(): {
controls: {
'private register*Endpoints(': occurrences(src, /private\s+register[A-Za-z]*Endpoints\s*\(/g),
'this.routeManager.register(': occurrences(all, /this\.routeManager\.register\(/g),
// [#23050 slice 3] The registrar list's length, so a module added to it
// makes this row re-read its numbers.
'registrar list length': REST_SERVER_REGISTRAR_FILES.length,
// [#23050 slice 1] The moved module's own share, so a reading that
// silently stopped appending it cannot hide behind the total above.
'data-action-routes.ts: this.routeManager.register(': occurrences(moved, /this\.routeManager\.register\(/g),
'batch-routes.ts: this.routeManager.register(': occurrences(movedBatch, /this\.routeManager\.register\(/g),
// [#23050 slice 3] One such control per module on the list.
...Object.fromEntries(movedModules.map(([file, text]) =>
[`${file}: this.routeManager.register(`, occurrences(text, /this\.routeManager\.register\(/g)])),
'registerPerItemRoute(': occurrences(src, /registerPerItemRoute\(/g),
'const registerPerItemRoute =': occurrences(src, /const\s+registerPerItemRoute\s*=/g),
// ⭐ The one control here that is NOT a whole-file count, deliberately:
Expand Down
Loading
Loading