Repository navigation
perf(rest): GET /data/OBJECT no longer computes metadata diagnostics; the exposure gate reads getMetaItemForExecution - #24666
Conversation
…execution face The by-name read gains an execution twin, getMetaItemForExecution, sharing every source, the overlay cache, merge, lock and governance step with getMetaItem and skipping only the serving decorations at the exits. The gate in enforceApiAccess reads it where the protocol has it, so a /data request no longer pays a full diagnostics safeParse of the object. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…d minus its decorations Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…e reads the execution face Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…ecution read Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
…own boundary As getMetaItems and getMetaItemsForExecution do: the shared body takes a request already folded. The read-verb fold population pin gains the new verb (seventeen). Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 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 de19defa0afc04acd318f4a6f3d1dce6c30e5326 && git checkout de19defa0afc04acd318f4a6f3d1dce6c30e5326
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eadea572082976b747e0828d56c24fdf62cd8803 a132ad0cff0a9361e47e838363f0f599616be4fe && git checkout -B drift-repro eadea572082976b747e0828d56c24fdf62cd8803 && git merge --no-ff a132ad0cff0a9361e47e838363f0f599616be4fe
node scripts/docs-audit/affected-docs.mjs --json eadea572082976b747e0828d56c24fdf62cd8803
|
席位核对 — ACCEPT (the lane seat's review of record for a
|
席位核对 — REWORK (patch round 1: Test Core (6/6) is red on this head)
What is red: Cause, read on this head: the rig ( Owed in this PR:
Production code: the seat's grep finds no production code that reassigns The test files are in Generated by Claude Code |
…te-execution-read
…m the registry enforceApiAccess reads getMetaItemForExecution when the protocol has it, so the rig's registry-backed by-name stub sits on both faces; with only getMetaItem stubbed the exposure gate read the unstubbed face, failed open, and every locked and hidden row answered the body gate's 400. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
席位核对 — ACCEPT (patch round 1)
Generated by Claude Code |
Fixes #24535
Clause-②: yes (widening): measured on each package's built
dist/index.d.tsatf89d29e38(before) and at this head (after); see "The export reading" below.@objectstack/metadata-protocolkeeps its export list (131 names), andObjectStackProtocolImplementationgains the public methodgetMetaItemForExecution.@objectstack/restkeeps its export list (81 names), andRestProtocolgains the optional membergetMetaItemForExecution?. Every other member signature is unchanged in both.The API-exposure gate on the
/datadoors (enforceApiAccess) reads the object it judges by name, and it uses onlyenable. It read through the servedgetMetaItem, which decorates the item with_diagnostics: a full schemasafeParseof the object's definition, paid on everyGET /api/v1/data/OBJECT. The card measured this at 1.84 ms of 15.60 ms CPU per HotCRM read. The by-name read now has an execution twin, as the list read already had. The gate reads that twin, and no served read changes.The factoring: what the two reads share and the one place they differ
packages/metadata-protocol/src/protocol.ts:getMetaItem(request)and the newgetMetaItemForExecution(request)each fold the type at their own boundary (canonicalizeMetaRequestType), asgetMetaItemsandgetMetaItemsForExecutiondo. Each then calls one private body,readMetaItemFace(request, face). That body isgetMetaItem's old body, moved: the disabled-package refusal (404 OBJECT_PACKAGE_DISABLED), thenreadMetaItem(request, { face }), then the disabled-package withholding.readMetaItem's optionservedRead?: boolean(from Authenticated data requests issue ~20 DB queries in ~17 sequential legs — per-request auth/session/localization/metadata resolution has no cross-request caching, costing ~1.5s/request on remote Postgres #10757 leg 6) becomesface?: 'served' | 'execution'. With either face, the item's active rows are read through the overlay cache (readItemActiveRows(..., cached)). The cache key is{ type, name }and does not depend on the face, so both faces share one entry. Authoring callers pass no face. They read the store, decorated, as before.serveItemisdecorateMetadataItem(_diagnosticscomputed on the raw body, then the type's redaction) on the served face, and the identity on the execution face. It applies at all three exits: the draft-preview return, the strict-draft return and the active return. The active exit also adds two things only on the served face: a view's cross-document reference diagnostics (merged into_diagnostics) andregistration. These follow the list read's precedent, whose execution face skipsdecorateMetadataItemsandregistrations. Every source, merge, lock, version token and governance step runs on both faces.getMetaItemsForExecution: ⛔ never serve what this returns from a door that answers a caller (HTTP, MCP, an export, a log line). A gate inside such a door may read a decision off it, as the exposure gate readsenable, but no byte of the body may reach the response.packages/rest:rest-server.ts:RestProtocol = DataProtocol & MetadataProtocol & { getMetaItemForExecution?: MetadataProtocol['getMetaItem'] }.enforceApiAccessasksgetMetaItemForExecutionwhen it is a function, elsegetMetaItem, else the list (the existing fallback). No other caller ofgetMetaItemchanges.meta-item-read-gate.tsis insidepackages/rest, so it is this lane's file. Only two doc comments change:getObjectItemandloadObjectItemnow say that REST hands in the execution face.loadObjectItem's contract is unchanged, because the execution face answers the same envelope and the same refusals:{ item }envelope;OBJECT_PACKAGE_DISABLEDmeans no object.The parity proof
Permanent:
packages/metadata-protocol/src/protocol.item-execution-read.test.ts(9 tests). In each row,decorateMetadataItem(type, execution.item)equalsserved.item: the served item minus_diagnosticsand redaction. Every other envelope key is equal exceptregistration. Each row also asserts that the served item is decorated, so the equality cannot hold vacuously.datasource). The execution item carries the stored password; the served item does not.NO_DRAFTon both faces.OBJECT_PACKAGE_DISABLEDon both faces, with the same message.registration, which only the served face carries.One-off proof that the served body is byte-identical to before. The previous build (
distatf89d29e38) and this head's build were run over identical hosts, and the JSON answers were compared byte for byte. The matrix was:getMetaItem,getMetaItemCached, and the authoringreadMetaItem;480 answers, 0 mismatches. The requests cover:
datasource(redaction),view(reference diagnostics), andflow(registrationand drafts).Two normalisations were applied, both outside the change:
getMetaItemCachedstampslastModifiedfrom the wall clock, so the clock is frozen.Control: the same harness comparing the old served face with the NEW execution face differs on 16 of 20 answers. Those are exactly the 16 decorated answers; the 3
NO_DRAFTrefusals and the one missing name are equal.The counting test, its ablation and its controls
packages/rest/src/rest-exposure-gate-execution-read.test.ts(12 tests) drives the real REST route handlers, over the realObjectStackProtocolImplementation, over a realSchemaRegistry. The code object's data call is the realfindData. Every diagnostic of an object goes through one seam,computeMetadataDiagnostics, which callsgetMetadataTypeSchema('object').safeParse. For this file, theobjectbinding is replaced through the registry's public overlay,registerMetadataTypeSchema: the same schema behind a proxy that records thenameof each document it parses. It is restored afterwards.Count:
GET /api/v1/data/OBJECTrecords zero diagnostics for a code-registered object and for an object stored in the environment, unscoped and environment-scoped. A request the gate refuses (405, verdict taken from the stored row'senable) also records zero.Fallback (in-test control on the same
/datapath): a protocol with no execution face is askedgetMetaItem. The answers are the same, and the test records exactly one diagnostic of that object. This row keeps the zeros from being vacuous: the instrument does see the gate's read when the read decorates.Control:
GET /api/v1/meta/object/NAMEstill serves_diagnostics, and the instrument records it, for both objects and both scopes.Ablation (one-off): run with
scripts/ablation-replace.mjsagainst the committed head32d829ffc. It replacedconst byName = typeof p.getMetaItemForExecution === 'function'withconst byName = false, so the gate readgetMetaItemagain (anchor count 1 to 0, blob1aa8eeb5a2b2to68a515a0f7c2). Result: 6 failed, 6 passed.expected [ 'code_widget' ] to deeply equal []andexpected [ 'stored_widget' ] to deeply equal [].1aa8eeb5a2b2), andgit diff HEADis empty.Three pins in the rest: every /data request's API-exposure gate reads EVERY object's metadata (with a full schema diagnostic) to check ONE object's enable flags — 62-72% of a data request's time, measured #24031 file (
rest-exposure-gate-one-object-read.test.ts) named the face the gate reads, and now name the execution face: the by-name spy, the throwing read, and the "no by-name read" case, which now means neither face.Cost reading (optional; shared box, so read the ratio). Measured on the built
distat32d829ffc: an object with N fields, a warm overlay cache, median per-read time:getMetaItemgetMetaItemForExecutionThe export reading (Clause-②)
dist/index.d.ts. Every export is listed with its kind, and every member of every exported class, interface and type alias is listed with its full untruncated signature. Lines are sorted, and the before and after lists are diffed order-insensitively. This was not a grep of a barrel line.@objectstack/metadata-protocol: 131 exports before and after, with the same names. The diff is exactly two added lines:ObjectStackProtocolImplementation member getMetaItemForExecution(the request type and the return type are the same asgetMetaItem's) andmember private readMetaItemFace.getMetaItem's signature line is identical.@objectstack/rest: 81 exports before and after, with the same names. The diff is exactly one added line:RestProtocol member getMetaItemForExecution?: ....minor, notpatch. AClause-②: yestakes at leastminor(AGENTS.md, Post-Task Checklist step 3), andcheck-changeset-no-major.mjs's level axis refusesyeswhen every moved package ispatch. Both published surfaces widen, so both are graded.Acceptance notes
scripts/engine-double-contract.pinned.jsongains two rows: the two new test files' pinnedfindOnedoubles.check:engine-double-contractprescribed--writefor these rows.getObjectadapter and the post-publish build probes'getItem. They run per publish, not per request, and are left on the served face. Carrier: none.Verification at head
8f2b83379(round 0; see Patch round 1 for the current head)All of the following ran after the last commit,
8f2b83379. Every exit code was captured before any pipe.pnpm --filter @objectstack/metadata-protocol typecheckexit 0, andpnpm --filter @objectstack/rest typecheckexit 0. The rest typecheck includescheck:test-typecheckover the test layer: 0 errors.pnpm --filter @objectstack/metadata-protocol test: 250 files passed, 3 skipped; 20780 tests passed, 19 skipped.pnpm --filter @objectstack/rest test(local): 301 files passed; 6107 tests passed, 326 skipped.pnpm --filter @objectstack/rest test:repo: 5 files passed; 278 tests passed, 18 skipped.a691f190f, turnedprotocol.read-verb-canonical-fold.test.tsred. That pin derives its population of/metarequest-boundary verbs from the code, and the new face was not yet in it.b163268a0makes each face fold its type at its own boundary, as the list faces do, and registers the seventeenth verb.eslint --no-inline-configover the seven changed source and test files exited 0, andpnpm lint(the full union) exited 0.node scripts/pm/dispatch-gates.mjs --commandsderived 78 commands. All 78 exited 0.--ranreports "78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN".a691f190f, turnedcheck:engine-double-contractred because the ledger lacked the two new pinned doubles. They were recorded at8f2b83379, and the gate is green.check-changeset-no-major.mjs --event): clean, becauseClause-②: yes (widening)is carried byminorgrades. On a throwaway local probe commit grading both packagespatch, the same command exited 1 and named both lines.Patch round 1 (head
a132ad0cf)8f2b83379: Test Core (6/6).packages/runtime/src/domains/data-write-request-gate.test.tsfailed 12 tests: everylockedandhiddenrow answered400 VALIDATION_FAILED. Its rig givesRestServera realObjectStackProtocolImplementationwith no metadata store, and it answered onlygetMetaItemfrom the registry. The exposure gate now readsgetMetaItemForExecution, which the rig left unstubbed, so the gate failed open and the body gate answered first.packages/runtime, declared in the seat's REWORK. The rig installs its one registry-backed by-name stub on both faces, and its note names both. Reverse check: with the stub ongetMetaItemalone, the file fails 12 of 73, which reproduces the CI red; with both faces stubbed, 73 of 73 pass.getMetaItem. Three of them reachRestServer's exposure gate on a real instance: this rig (fixed) and this PR's two exposure-gate files (already on the execution face). The other 15 never mount that instance inRestServerbehind a/datadoor. Beyond those, 98 test files overridegetMetaItemand name the REST layer; the rest of them handRestServeran object-literal protocol, which has no execution face, so the gate still asks their override.getMetaItemon a protocol instance or subclassesObjectStackProtocolImplementation, andRestApiPluginhandsRestServerthe kernel'sprotocolservice unwrapped.objectstack-ai/cloudis not measured here.origin/main:e47f72d38, a merge commit, clean.a132ad0cf:@objectstack/runtime: typecheck exit 0.test: 425 files; 6678 passed, 19 skipped.test:repo: 3 files; 808 passed.@objectstack/metadata-protocol: typecheck exit 0.test: 250 files passed, 3 skipped; 20790 tests passed, 19 skipped.@objectstack/rest: typecheck exit 0.test: 301 files; 6107 passed, 326 skipped.test:repo: 5 files; 278 passed, 18 skipped.pnpm lint, exit 0.--ranreports 0 NOT-MEASURED and 0 UNRUN.a132ad0cf: every check green, including Test Core (6/6).Generated by Claude Code