Skip to content

fix(rest)!: the batch door judges each op's API exposure on the object the data door serves (#27899) - #27963

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-5rhjdy
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-5rhjdy

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Fixes #27899

domain:server#4 · os-elon-musk · session_016WV4cwgfUuNAvcUg3JzBiR

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no hand-written docs page or published skill states what the cross-object batch door answers about an object's API exposure, or how a short name resolves there (git grep of content/docs outside references//releases/ and of skills/ for apiEnabled, apiMethods or OBJECT_API_DISABLED beside the batch door: 0 hits; http-protocol.mdx names the door only as the transactionalBatch capability).

维护者速读

  • 改了什么:跨对象批量接口 POST /batch 的逐操作 API 暴露闸,改用 /data/OBJ 暴露闸的同一个按名读法(从 enforceApiAccess 抽成 RestServer 的一个私有方法,两处共用)。每个操作判的,就是数据门按这个名字会服务的那个对象;拒绝时回的状态码与正文和数据门一字不差。
  • 为什么改:按分诊 6514615922,这是 rest: POST /data/:object/import (synchronous import) on an unregistered object answers 200 with a per-row OBJECT_NOT_FOUND, where POST /data/:object answers 404 #27728 / rest: pin the async import job door's disabled-package refusal, and decide whether it should resolve a legacy ns__short name as the data door does #27729 一族的最后一扇门。原先它按声明名在对象清单里找对象,清单不做旧式 ns__short 短名解析,于是这类对象用短名时被当成未知对象放行,答案与 /data 门不一致(实测,见 Tests)。
  • 风险与回滚:收窄,changeset 已写 BREAKING 说明与 ADR-0087 处置;只影响用短名指向带 __ 的旧对象、且该对象限制了 API 暴露的批量操作,已发运的对象都不带 __。读法由每请求一次清单读变为每个不同对象一次按名读。回滚即还原 batch-routes.ts 的闸块与 rest-server.ts 的私有读法两处。
  • 先看:packages/rest/src/batch-routes.ts(逐操作闸块);packages/rest/src/rest-server.ts 的 loadObjectItem(从 enforceApiAccess 抽出,并接到批量路由的 host 上);新测试 packages/rest/src/batch-op-exposure-object-read.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/batch-op-exposure-object-read.test.ts,预期 9/9 通过。
  • 需要你做的:无,席位落地(契约复审由席位安排)。

What changes

  • One read for every data door's exposure gate. The read the /data/:object gate (enforceApiAccess) made inline — loadObjectItem in meta-item-read-gate.ts, over the protocol's by-name execution read, or its list read for a protocol with no by-name read — is now one private method, RestServer#loadObjectItem(p, environmentId, name). enforceApiAccess calls it for the route's :object, and the cross-object batch route calls it for each op's object through BatchRouteHost.
  • The batch door's per-op gate. It used to read the whole object list once and look each op up by the item's declared name. The by-name read resolves a legacy ns__short object from its short name as the data door's registry.getObject does; the list does not. The gate now judges the object the data door serves for the op's name, so such an op is refused with the data door's answer, status and body (apiAccessDenialFromEnable, unchanged), before the transaction opens. Each distinct object is read once per request; each distinct (object, action) is judged once, in op order; the first refusal answers for the whole batch, as before.
  • The private list read retired. RestServer#loadObjectItems had this gate as its only caller, so it is gone, and BatchRouteHost carries loadObjectItem in its place. The two 元数据不可解析时的 fail-open 残余风险评估(api-exposure / rest-server) #3545 observability pins in rest.test.ts that called it by name now call the method that replaced it, with the same assertions (fail open, a thrown read logged, an empty registry silent). Docblocks that named the batch as a reader of the object list now say otherwise (meta-item-read-gate.ts, metaListSource, one line of objectql-slot-consumer-census.test.ts).

Why

Triage 6514615922: the /batch per-op gate reads each op's object through the same read as the /data gate and refuses the same way. PR #27897 gave the /data gate this read; the batch door was the last data door that answered differently about whether an object is exposed.

Tests

New packages/rest/src/batch-op-exposure-object-read.test.ts: a real ObjectQL engine, the real protocol and the real RestServer routes on a sqlite :memory: driver, with objectQLProvider wired so the batch door is reachable, and the metadata store registered (without it both object reads answer SERVICE_UNAVAILABLE and every exposure gate fails open by design, #3545). Fixtures: legacy-named objects of one package (one unexposed, one exposed for reads only, one exposed), addressed by their short names, and one plain-named unexposed object.

  • Refusal pins (red before, green after). A create, update or delete op on the unexposed object: status and body equal the /data/:object answer for the same name, the transaction never opens, no row. A write the object does not expose in bulk: status and body equal POST /data/:object/createMany's 405. One refused op refuses the whole batch: an exposed op ahead of it is not written.
  • Controls (green before and after). Premise: the data door resolves the short name and refuses the unexposed object. The full name is refused, as it already was. An exposed object is written under its short name and its full name in one batch. A plain-named unexposed object is refused, as it already was.

Before and after, through the real POST /batch door (base d4064cc1 with only the test committed, then head 2019e190):

op before after
create, unexposed object 200, row written 404 OBJECT_API_DISABLED, equal to /data
update / delete, unexposed object 404 RECORD_NOT_FOUND (passed the gate, reached the engine) 404 OBJECT_API_DISABLED, equal to /data
write the object does not expose in bulk 200, row written 405 OBJECT_API_METHOD_NOT_ALLOWED, equal to createMany
exposed op, then an unexposed op 200, both written 404, nothing written
the four controls as listed above unchanged
Red first, ablation, package suite
  • Red first, ec6f33ef..db7513a4 (test only, on the base code): Tests 5 failed | 4 passed (9), the five refusal pins red and the four controls green.
  • Fix, 2019e190: Tests 9 passed (9); with rest-batch-endpoint, rest, import-door-object-existence, objectql-slot-consumer-census, rest-batch-blank-id: Test Files 6 passed (6) · Tests 294 passed (294).
  • Ablation (scripts/ablation-replace.mjs, anchor hit once, blob 0ce906d7 to 23cf0acd): the gate counted the read item only under the op's declared name, the old list semantics. Tests 5 failed | 4 passed (9), the same five red. Restored: blob equals HEAD 0ce906d7, git diff HEAD empty.
  • pnpm --filter @objectstack/rest test: Test Files 351 passed (351) · Tests 6775 passed | 332 skipped (7107), lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/rest typecheck: lock VERDICT command-exit 0.

Acceptance notes

  • Read cost. The batch gate made one object-list read per request; it now makes one by-name read per distinct object of the batch, on the protocol's execution face, which decorates nothing. A batch names few distinct objects, and the batch size cap still bounds it.
  • Fail-open logging. A thrown object read still fails open and logs (元数据不可解析时的 fail-open 残余风险评估(api-exposure / rest-server) #3545), measured unchanged in kind: without the metadata store, the list read and the by-name read both answer SERVICE_UNAVAILABLE, so the gate's verdict in an outage is the same as before. The log is now one line per distinct object rather than one per request.
  • Disabled package. An object of a disabled package reads as no object here, as the list omitted it, and the op is refused inside the transaction by the data plane, as before (loadObjectItem's OBJECT_PACKAGE_DISABLED branch; read from the code, not measured here).
  • objectql-slot-consumer-census.test.ts keeps its measured sentence about where a real op goes on the multi-kernel harness; only the method name changed, the sentence was not re-measured.
  • origin/main moved after the branch point (d4064cc1..ed0e71db); its packages/rest changes touch other files, and git merge-tree against it is clean, so the branch was not merged.

Verification

Head 2019e190, base d4064cc1, after a whole-tree build (turbo run build --filter=!@objectstack/docs, lock VERDICT command-exit 0).

Derived gate families and lint

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 61 families over the 7 changed paths. All 61 exited 0, and pnpm lint exited 0. --ran reconciliation: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN; pnpm lint recorded outside the derivation. check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.

Dogfood battery (narrowing arm), head 2019e19

pnpm --filter @objectstack/dogfood test under the verify lock, no shard: Test Files 357 passed | 3 skipped (360) · Tests 2875 passed | 20 skipped (2895), lock VERDICT command-exit 0.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (Not touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4cwgfUuNAvcUg3JzBiR

…e data door serves

The cross-object batch door's per-op exposure gate now reads each op's
object through the same by-name read as the /data/:object gate, moved into
one private RestServer method both gates call, instead of keying the object
list by declared name. Each distinct object is read once per request.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cwgfUuNAvcUg3JzBiR
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 7 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/meta-item-read-gate.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 5 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v12.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via RestServer (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/meta-item-read-gate.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: enforceApiAccess (10 routes)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ed0e71db94a73acc02512ff2ee0655106d02e585 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2f98458f404ccb30b57872a9fc28555983230977 — the merge of head 2019e190f5c40af31fbabccf6fb2d5a3fa612e28 into base ed0e71db94a73acc02512ff2ee0655106d02e585, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2f98458f404ccb30b57872a9fc28555983230977 && git checkout 2f98458f404ccb30b57872a9fc28555983230977
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed0e71db94a73acc02512ff2ee0655106d02e585 2019e190f5c40af31fbabccf6fb2d5a3fa612e28 && git checkout -B drift-repro ed0e71db94a73acc02512ff2ee0655106d02e585 && git merge --no-ff 2019e190f5c40af31fbabccf6fb2d5a3fa612e28

node scripts/docs-audit/affected-docs.mjs --json ed0e71db94a73acc02512ff2ee0655106d02e585

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ed0e71db94a73acc02512ff2ee0655106d02e585 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head 2019e19 · 2026-10-30T05:16Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2019e190f5c40af31fbabccf6fb2d5a3fa612e28
Local-runs: none
Scope: packages/rest/src/batch-routes.ts, packages/rest/src/rest-server.ts, packages/rest/src/meta-item-read-gate.ts, packages/rest/src/rest.test.ts, packages/rest/src/objectql-slot-consumer-census.test.ts, packages/rest/src/batch-op-exposure-object-read.test.ts, .changeset/27899-batch-op-exposure-object-read.md + packages/rest/src/meta-item-read-gate.ts (loadObjectItem, loadObjectItems, MetaReadGateListSource), packages/rest/src/rest-server.ts (enforceApiAccess, objectItemReader, metaListSource, apiAccessDenialFromEnable, the BatchRouteHost literal), packages/objectql/src/registry.ts (getObject, resolveObjectKey, getItem), packages/metadata-protocol/src/protocol-meta-item-face.ts (readMetaItemFace), packages/metadata-protocol/src/protocol.ts (readMetaItem step 3 and its degraded arm), packages/spec/src/api/batch.zod.ts (CrossObjectBatchOperationSchema), scripts/check-adr-0087-registration.mjs (the category list), scripts/adr-anchors/packages__rest__src__rest-server.ts.json

Read at 2026-10-30T05:29Z against the net diff origin/main (ed0e71db)...2019e190 (7 files, +278/−46; the PR's file list is the same seven), card #27899 (body, triage 6514615922, claim 6514787457 with its seat amendment, dev report 6515574134), PR #27963's body, merged PR #27897 with its records 6513915701 / 6513977097 and ACCEPT 6514232261, and the head's check-runs. Face: narrowing, from the Clause-②: no (narrowing) line on the PR body and the changeset; no published schema and no governed path in the file list. Adversarial read: the PR body, the dev report and the dispatch were inputs to test, not findings to adopt; triage's ruling taken as given, not re-ruled. This card is an API exposure gate: everything below is stated at rule level.

① Derived judgments

  1. Every op kind of the cross-object batch now reads its object through the one read the /data gate uses, and refuses with the same envelope. The spec's op schema (CrossObjectBatchOperationSchema) admits exactly create, update, delete (default create) on a non-empty object string, so the per-op loop in batch-routes.ts covers every op kind the door can parse. Each distinct op.object is read once through this.loadObjectItem(p, environmentId, op.object); each distinct (object, action) is judged once, in op order; the first denial answers for the batch; the transaction has not opened. The denial is apiAccessDenialFromEnable(obj.enable, op.object, 'bulk', { bulkChild: op.action }), unchanged, and the per-object bulk routes call enforceApiAccess(…, 'bulk', context, { bulkChild: 'create' | 'update' | 'delete' }), which reaches the same function with the same requested name: 404 OBJECT_API_DISABLED for apiEnabled: false, 405 OBJECT_API_METHOD_NOT_ALLOWED naming the failed conjunct for a whitelist that leaves bulk or the child out. Same status, same code, same body shape. Correct.
  2. The replacement read is the /data gate's read, byte for byte, not a sibling of it. enforceApiAccess used to assemble metaListSource + objectItemReader and call metaReadGate.loadObjectItem inline; the diff moves that assembly into RestServer#loadObjectItem(p, environmentId, name) and makes enforceApiAccess call it, so the data door and the batch door cannot drift on which face or which envelope they read. objectItemReader prefers getMetaItemForExecution, else getMetaItem, else undefined (then loadObjectItem falls back to the list with its exact-name match, the one non-resolving arm, unchanged from fix(rest)!: both import doors answer for the object as the data door does (#27728, #27729) #27897). Correct.
  3. The accept-set change is exactly the short-name alias set, and nothing else. The by-name read's step 3 is registry.getItem('object', name) → getObject(name) → resolveObjectKey: short-name index first, then FQN. The stored-row and MetadataService steps before it are keyed by the requested name. So for a non-legacy object the by-name read serves the same item the list held under that name, and the verdict is unchanged; for a legacy ns__short object addressed by its short name, the gate now judges that object's enable, where the list keyed by declared name found nothing and passed the op through to an engine that resolves the alias. The data door has judged that alias since fix(rest)!: both import doors answer for the object as the data door does (#27728, #27729) #27897. An ambiguous short name resolves the same way on both doors (first match, warned). No object that passed legitimately is newly refused: an exposed object passes under either name (pinned, two names in one batch), a plain-named restricted object was already refused (pinned). Correct.
  4. The refusal order relative to auth and the credential refusal is unchanged. The handler's sequence on main and on the head is identical outside the gate block: enforceAuth → engine presence (501) → schema parse (400) → atomic (400) → empty (200) → enforceBatchSize → missing-id (400) → the exposure gate → apiKeyManagementCredentialRefusal per op (403, after the exposure verdict so an unexposed op keeps its 404/405, before the transaction) → $ref resolution inside the transaction. Only the gate block's body moved. Correct.
  5. Fail-open and its log are preserved by the replacement read. loadObjectItem on a thrown by-name read logs the [REST] api-exposure gate: … failing open line and answers undefined; OBJECT_PACKAGE_DISABLED answers undefined silently (the list omitted such an object, so the verdict matches); an absent name answers an empty envelope, so "no object" is silent; a degraded store throws 503 at the protocol and is the logged fail-open. The batch loop treats undefined as the old !obj → continue. What changes is cardinality: the old code skipped the loop entirely on an empty list (one log per request during an outage); the new code reads each distinct object (one log per distinct object per request). Same verdict, same posture, the dev names it. Accepted; not a defect.
  6. The two 元数据不可解析时的 fail-open 残余风险评估(api-exposure / rest-server) #3545 pins still measure a thrown read. rest.test.ts's createMockProtocol has no getMetaItemForExecution, so objectItemReader takes getMetaItem, and the describe-local setup defines getMetaItem as a read of getMetaItems at call time: the mocked rejection of the list reaches the by-name read, the pin asserts undefined plus a warn containing api-exposure gate; the empty-registry pin asserts undefined and no warn. The assertions are the originals re-pointed, as the claim amendment allowed. Correct.
  7. Dropping the private loadObjectItems leaves no caller, static or dynamic. git grep of the whole head tree (including ui/, scripts/, dogfood): the only loadObjectItems references are the module-level function in meta-item-read-gate.ts (still used by the nav-servability gate and the list fallback) and docblock prose in other packages that names that module function. No string-literal or bracket spelling, no (rest as any).loadObjectItems outside the two re-pointed pins, no ADR anchor on the moved text (rest-server.ts's anchor pins filterAppForUser, untouched). BatchRouteHost is not re-exported from the package entry, so the renamed member is no published type change. Correct.
  8. Public surface. No route, status, code or body is added or changed; the two refusal envelopes were already published on every /data/:object route. The one accept-set change is the narrowing in item 3. Metadata-protocol: none is right: no key, value or type an author can write moves.

② Semver level

@objectstack/rest patch with a line-initial **BREAKING** banner: correct under Post-Task Checklist step 3 (a non-protocol narrowing ships patch with its banner and ADR-0087 disposition), and the same level the family's merged changeset took. Clause-②: no (narrowing) and Metadata-protocol: none are line-initial exactly once in the PR body and in the changeset body, and match the diff: the arm is a narrowing (an op the door used to write is refused), nothing widens. The ADR-0087 marker is exactly one, not-required (no-migration-prescription), a category the gate's list holds; its justification is true against the diff (no key added, removed, renamed or re-shaped; no export change; the two refusals are already published). Before / Now / Who sees it / Remedy are each true against the code: the remedy correctly says the short name now answers as the full name does, and names lifting the enable restriction as the fix. Title carries !. Skills-docs: none — REASON is present with its git grep evidence. Check Changeset is green on this head.

③ Boundary flags

  • Check-runs on 2019e190, read at 2026-10-30T05:29Z: 47 runs; 33 success (among them Governed Surface Queue Guard, Check Changeset, Check PR Size, PR-body guards, Build Core, Type Check · workspace / source gates / consumer gates / debt ledger, Lint · ESLint, Lint · repo & tooling gates, Lint · docs & code guards, Lint · scoped · all four, Lint · PM dispatch-gates, Test Core shards 1, 2, 3, 5, 6, 7 and 8 of 8, Test UI, Live E2E UI, Dogfood Regression Gate), 13 skipped by the path filter, one shard (Test Core (4/8)) still in progress at this read, 0 failed, 0 cancelled. Pending is pending: the seat reads that shard green before any landing act. No governed surface in the file list; mergeable: true, state blocked (awaiting review and the running shards), base ed0e71db.
  • Dev deviations (5), each read against the card thread: (i) file surface widened before any push, recorded in claim 6514787457's seat amendment, and the diff stays inside that amended list; (ii) the first red run had no metadata store and every gate failed open, the fixture was corrected before the red-first reading, the throwaway debug test never committed, consistent with the head tree (the fixture registers the four sys_metadata* objects and says why); (iii) the dev stopped its own build, not another's, as Multi-agent discipline §8 requires; (iv) origin/main moved after the branch point, the dev did not merge; the net diff here is read three-dot against the current origin/main and GitHub reports mergeable: true; (v) no PR assignee, because the harness offered only an MCP write: the PR's assignee mirrors the card's, so the seat sets it through the relay before landing, a seat item, not a code item. open_questions: [], none owed.
  • Out-of-scope note from the dev, read: the fail-open log is now one line per distinct object (① item 5) and the census docblock's sentence was renamed, not re-measured; the renamed sentence still names the method the healthy-control path reaches, which is true on the head. Accepted as notes.
  • Family closure. PR fix(rest)!: both import doors answer for the object as the data door does (#27728, #27729) #27897's records left this door as the last data door with the old read, and the seat's ACCEPT filed rest: the cross-object POST /batch per-op exposure gate keys objects by name, so a legacy ns__short object addressed by its short name keeps the pass-through #27728/#27729 closed on the /data routes #27899 for it. With this diff every door that asks an object's API exposure asks RestServer#loadObjectItem: the /data/:object routes through enforceApiAccess, the per-object bulk routes through the same, the cross-object batch per op. The one non-resolving arm left is loadObjectItem's list fallback for a protocol with no by-name read, which no shipped protocol is.
  • Observation, no carrier, not this PR's debt: packages/metadata-protocol/src/meta-overlay-cache.ts line 7 still describes the hot path as enforceApiAccess → loadObjectItems, stale since the by-name read landed (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), outside this diff.

④ Design judgment

packages/rest/src/batch-routes.ts — holds against the code it cites and the scenario it is accepted against. The gate block reads each op's object through the host's loadObjectItem, the same member enforceApiAccess reads, so the batch judges the object the data door serves for the op's name (registry.getObject's resolution, reached through readMetaItem step 3). Dedup is per distinct object for the read and per distinct (object, action) for the verdict, in op order, so cost is bounded by the batch-size cap and the first refusal still answers for the whole batch before the transaction. undefined keeps the #3770 pass-through, as before. The BatchRouteHost member is typed as the class member, so the host literal and the interface cannot disagree. The credential refusal and $ref resolution are untouched and keep their positions. No must-fix.

packages/rest/src/rest-server.ts — holds. loadObjectItem is the former inline body of enforceApiAccess, moved, with enforceApiAccess now a caller; the docblock records why the batch door reads through it and what the list lacked. The private list read is gone with its only caller; metaListSource's docblock no longer names the batch route among the list's readers, which is true. The host literal hands the new member and no other. No must-fix.

packages/rest/src/meta-item-read-gate.ts — docblock-only, and the sentence is true on the head: the list is read by the nav-servability gate and by the by-name read's fallback; every data door's exposure gate reads by name. No must-fix.

packages/rest/src/rest.test.ts — the two re-pointed pins still measure a thrown read and a silent empty registry on the replacement member (① item 6). No must-fix.

packages/rest/src/objectql-slot-consumer-census.test.ts — one docblock word; the renamed method is the one the path reaches. No must-fix.

packages/rest/src/batch-op-exposure-object-read.test.ts — the pins are the right shape: a real engine, the real protocol, the real routes, the metadata store registered so the gate is live rather than failing open; each refusal asserts status and whole body toEqual the data door's or the bulk door's answer for the same name, the transaction spy never called, zero rows counted at the engine; the short-name premise is asserted before it is relied on; controls cover the full name, an exposed object under both names in one batch, and a plain-named restricted object. Should-fix (test-side, not blocking): the suite does not pin the one accept-set answer the replacement read must keep on this door, that an op naming an unregistered object still passes the gate and is answered inside the transaction (the #3770 pass-through). No existing batch test pins it either; the code path is the data door's own, so the risk is low, but the next change to loadObjectItem's absent-name arm would otherwise be caught only on /data.

.changeset/27899-batch-op-exposure-object-read.md — frontmatter, both declaration lines, one ADR-0087 marker, BREAKING banner, Before / Now / Who sees it / Remedy, each sentence true against the diff; rule-level throughout, no command or fixture reproduced. No must-fix.

No must-fix. The narrowing takes away only an answer that was wrong (a write the data door would have refused under the same name), and makes the spec's own refusal (ADR-0049 enable.apiEnabled / apiMethods) real on the last data door that read differently.

Implemented-by: claude/project-thread-5rhjdy
Reviewed-by: session_016WV4d1Np3hU7RQnA13YZcL

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

维护者速读

席位意见:ACCEPT。

席位核对

Head-sha: 2019e190f5c40af31fbabccf6fb2d5a3fa612e28

  • Shape: every /batch op kind reads its object through the private read enforceApiAccess now also calls (RestServer#loadObjectItem, registry.getObject with the legacy short-name resolution) and refuses through the unchanged apiAccessDenialFromEnable(…, 'bulk', { bulkChild }), so status, code and envelope equal the /data door's; the refusal order (auth → parse → size → missing id → exposure → per-op credential refusal → transaction) is unchanged; fail-open with a log on a thrown read is kept (one log per distinct object per request); the private list read loadObjectItems is dropped with no remaining caller. Changeset @objectstack/rest patch with a BREAKING note and ADR-0087 disposition, Clause-②: no (narrowing), Metadata-protocol: none. check-governed-merges --pr 27963: NOT governed, 324 changed lines.
  • Premise measured: red first on base 5 failed / 4 passed, after 9/9; ablation turns the same 5 red, restored to HEAD's blob; @objectstack/rest 351 files / 6775 tests; dogfood battery green (dev report 6515574134 on rest: the cross-object POST /batch per-op exposure gate keys objects by name, so a legacy ns__short object addressed by its short name keeps the pass-through #27728/#27729 closed on the /data routes #27899). Rule-level only on public surfaces.
  • Gate check-runs on the head: 48 runs, 35 success, 13 skipped, 0 failure/pending; mergeable_state clean.
  • Contract review: PASS, record 6515741450 on this head, no must-fix. Its one should-fix (a pin that an op naming an unregistered object still passes the gate and is answered inside the transaction) is not on this head; the dev wrote it and parked it unpushed for a later card. Observation outside the diff: metadata-protocol/src/meta-overlay-cache.ts docblock still names loadObjectItems (stale since 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).
  • Accepted deviations: the file surface took rest-server.ts, two rest.test.ts pins and two comment-only lines by claim amendment before the edit.
  • Landing: under the maintainer's landing grant for this seat term (seat 4 thread, 2026-10-29 22:04Z): pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 05:39
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 30, 2026 05:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 5f6dc96 Oct 30, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-5rhjdy branch October 30, 2026 06:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant