Skip to content

fix(rest): the API-exposure gate reads the one object it checks, by name, instead of every object's metadata on each /data request - #24093

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24031-exposure-gate-one-object
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24031-exposure-gate-one-object

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24031

Clause-②: no

Basis for the Clause-② line: the gate answers every request as before, with the same 405s, the same fail-open and the same answer for an unknown object; only the read behind the verdict changes, and no schema, published text or accept set moves. For a denied operation on an object whose package is disabled, the refusal's status and code change: FROM 404 OBJECT_PACKAGE_DISABLED TO the exposure verdict (405 OBJECT_API_METHOD_NOT_ALLOWED, or 404 OBJECT_API_DISABLED for apiEnabled: false). No accepted request becomes refused and no refused request becomes accepted (seat ruling 6226615729, Q2 → B).

What changed

  • packages/rest/src/rest-server.ts, enforceApiAccess only. The gate asks the protocol's by-name read, getMetaItem({ type: 'object', name }), for the one object in the path. It sends the same env-scoped request shape the list read sends, and it no longer calls loadObjectItems. A protocol with no by-name read is asked the list, as before.
  • packages/rest/src/meta-item-read-gate.ts: loadObjectItem, beside loadObjectItems.
    • A thrown read fails open and logs the same api-exposure gate warning (元数据不可解析时的 fail-open 残余风险评估(api-exposure / rest-server) #3545).
    • No item behind the name means no policy, and nothing is logged.
    • An item counts only under its own name, the same match the list search made.
    • MetaReadGateListSource gains one optional by-name method, getObjectItem.
    • loadObjectItems is unchanged for the batch route and the nav-servability gate. One docblock sentence no longer names this gate as a caller.
    • Nothing is cached, so a metadata change applies from the next request.
  • Pin: packages/rest/src/rest-exposure-gate-one-object-read.test.ts (37 tests). It runs the real protocol over a real SchemaRegistry, both unscoped and environment-scoped. It pins:
    • a /data list, get, create, update and delete each make zero getMetaItems calls, and therefore run no list-wide diagnostic pass;
    • the 404/405 answers for a code-registered object, a stored environment object and an object another package extends (an extension carries no enable, because ObjectExtensionSchema is strict);
    • an unknown object is still answered by the data path's own 404, with nothing logged;
    • a throwing by-name read fails open and logs;
    • a protocol with no by-name read is still asked the list;
    • for a disabled package's object, one DENIED operation (405) and one ALLOWED operation (still 404 OBJECT_PACKAGE_DISABLED).
  • Doubles (seat ruling Q1 → A): rest.test.ts, rest-bulk-path-object.test.ts, rest-export-permission-gate.test.ts. Each double's getMetaItem now answers the object its own getMetaItems holds. On the real protocol the two reads agree for every one of 104 showcase objects. The diff touches double setup only: no changed line contains expect(, it(, describe(, toBe or toEqual.
  • packages/runtime/src/domains/data-write-request-gate.test.ts, its rig only (Q1 A extended, 6227635439). The rig's real protocol has no metadata store, so its by-name read threw and the gate failed open on 12 exposure rows. Its getMetaItem now answers the same registry definition its getMetaItems override holds; every other type keeps the real read. No assertion changed. The earlier rounds' "existing exposure tests" grep named only packages/rest, and this rig sat outside it. A census of every non-rest test that constructs RestServer found it the only member of the class.
  • scripts/engine-double-contract.pinned.json. One entry, generated by check-engine-double-contract --write for the new pin file. The seat declared it on [PM seat] domain:spec — ⏳ vacant #6017 (comment 6226622031).
  • .changeset/24031-exposure-gate-one-object-read.md (@objectstack/rest patch): the latency gain, the one status/code change (FROM/TO), and Clause-②: no.

Measurements (showcase, bootShowcase, shared box)

  • Object-list reads per /data request, base gate → head: 1 → 0. Measured on list, get, create, update, delete, a refused request, a stored object and an unknown object.
  • 33 × GET /api/v1/data/showcase_account?limit=5: 2421 ms → 647 ms in total, median 71.7 → 19.5 ms (-73 %).
  • The read behind the gate: the list read has a median of 56-61 ms; the by-name read has a median of 4.5 ms. One by-name read makes 0 calls to getMetaItems, readFlattenedMetaItems and registry.listItems('object').
  • Agreement of the two reads (before the merge, at e10990366; the merge brought no hunk inside getMetaItems, readFlattenedMetaItems or getMetaItem):
    • 104 objects (68 of them API-restricted), on an unscoped and an env-scoped protocol: enable is deep-equal for every one;
    • a stored env object is equal in both reads;
    • a draft-only object is absent from both;
    • a stored row under a packaged object's name is declined by both;
    • an unknown object: getMetaItem does not throw, and its envelope has no item;
    • the one class that differs is a disabled package's object (see the Basis).
  • Disabled-package doors (com.objectstack.plugin-auth disabled), base tree e246f0611 → head e238774dd:
    • POST /api/v1/data/sys_api_key: 404 OBJECT_PACKAGE_DISABLED → 405 OBJECT_API_METHOD_NOT_ALLOWED;
    • GET /api/v1/data/sys_api_key: 404 OBJECT_PACKAGE_DISABLED at both.

Re-measuring the Rerun Safety file of #23977 is the seat's step once this lands; #23977 stays open here.

Tests (at 70eca6c17; the @objectstack/rest readings are at e238774dd, whose rest sources are the same)

  • This round, at 70eca6c17:
    • full @objectstack/runtime local project: Test Files 405 passed (405); Tests 6175 passed | 19 skipped;
    • @objectstack/http-conformance: 14 files, 150 passed;
    • the runtime rig: 73 / 73, with 0 gate fault lines, and 73 / 73 under the ablation too, so its verdict rows hold under both reads;
    • @objectstack/runtime typecheck exit 0, with the debt ledger unchanged.
  • Full @objectstack/rest local suite: Test Files 290 passed (290); Tests 5990 passed | 326 skipped; VERDICT command-exit 0.
  • The pin file plus the 3 ruled files: 4 files passed, 265 tests passed.
  • pnpm --filter @objectstack/rest typecheck: exit 0, including check:test-typecheck with 0 debt.
  • Ablation at e238774dd:
    • Mutation: enforceApiAccess put back on loadObjectItems through scripts/ablation-replace.mjs (anchor 1 → 0, blob ffc716c6a290 → b429a90b19d3). The dist preflight found the marker, exit 0.
    • Result: 17 failed | 248 passed. Red: the zero-read rows, the refused-request zero-read rows, the asks-by-name rows and the throwing-by-name rows in both scopes (16), plus the disabled-package DENIED row (its base answer is 404 OBJECT_PACKAGE_DISABLED). Green: every answer row, and all 228 tests of the 3 ruled files, so their doubles keep their verdicts under both reads.
    • Restore: blob == HEAD and git diff HEAD is empty; the preflight with --absent exits 0; 265 passed.
  • The implementation alone (before the doubles, at 3f612b8d5): the 9 tests in those 3 files went red, and the rest of the suite was green.
  • test:repo was not run locally, because the diff touches none of its files. CI's Test Core runs it.

Gates (at 70eca6c17)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 75 families, the same set as at e238774dd. All 75 were re-run at 70eca6c17 and every one exited 0; check:dual-build-cjs-loads was included and ran after a full build. The --ran reconciliation exited 0.
  • pnpm exec eslint --no-inline-config over the 6 changed TypeScript files exited 0. The repo-wide lint is the Lint & Repo Gates check-run's.

Acceptance notes

  • Three other paths still read the whole object list on every request (not measured):

    • POST /api/v1/data/batch, which reads it once for all its operations and is held off by the ruling;
    • the anonymous form doors: readFormObjectDefinitions, and the form schema read, which lists every object to find one.
  • Registry hydration. On an UNSCOPED kernel, the list read hydrates stored rows into the registry, and the gate no longer triggers that on every request. The designed paths still do it:

    Standalone serve boots are env-scoped (env_local), where that hydration never ran.

  • Two docblocks still name this gate as a list caller: the private loadObjectItems docblock in rest-server.ts, which is outside this surface, and the meta-overlay-cache.ts header in packages/metadata-protocol, which is held off.

  • The disagreement the Q2 ruling leaves to another lane. The by-name read serves a disabled package's object while the list omits it; the READERS table of Package disable and uninstall never reach the metadata/data layer: a disabled package's objects still serve rows, and uninstall leaves 7 orphaned sys_metadata rows #7557 classifies neither. Measured with com.objectstack.plugin-auth disabled, identical at base e246f0611 and head e238774dd:

    • GET /api/v1/meta/object/sys_api_key answers 200 and serves the object;
    • GET /api/v1/meta/objects and GET /api/v1/meta/object answer 200 with 73 items, and sys_api_key is absent (103 items while the package is enabled).

    The seat files this as its own card.


Generated by Claude Code

enforceApiAccess asked for the whole object list and searched it for one
name. The list read decorates every object with a full schema diagnostic,
so every /data request paid a diagnostic pass over the whole registry to
read one object's enable flags. The gate now asks the protocol's by-name
read (getMetaItem) for that object. A thrown read still fails open and
logs; no object behind the name is still no policy; a protocol with no
by-name read is asked the list as before. Nothing is cached.

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…er /data request

Over the real protocol and a real SchemaRegistry, unscoped and
environment-scoped: list, get, create, update and delete each make zero
getMetaItems calls; the gate's 404/405 answers are unchanged for a
code-registered, an extended and a stored object; an unknown object is
answered by the data path's own 404 with nothing logged; a throwing
by-name read fails open and logs; a protocol with no by-name read is
asked the list.

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…limit, and its findOne double is ledgered

check:objectql-double-limit graded the new file's find double limit-blind;
it now applies a numeric limit after the filter. check:engine-double-contract
asked for the new file's findOne double in the pinned ledger
(check-engine-double-contract --write added that one entry and nothing else).

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…st does; pin the disabled-package class; changeset

Three exposure suites' protocol doubles answered getMetaItem with a body
their own getMetaItems contradicted. The real protocol's two reads agree,
so each double's by-name read now answers the object its list holds; no
assertion changes. The gate pin gains the one class whose answer moves:
on a disabled package's object a denied operation answers the exposure
verdict (405), and an allowed one still reaches the data plane's 404
OBJECT_PACKAGE_DISABLED.

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

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: enforceApiAccess (9 routes)
  • the SDK route bridge reached 54 of 206 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 — 18 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 6e180163def1d31004905e4d6aac2d413571fb8e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f2739775189f3f0f7caf49b74775fce4102c28c5 — the merge of head 70eca6c17eb6dbe65bd6b4ecf4d78a3e89032b39 into base 6e180163def1d31004905e4d6aac2d413571fb8e, 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 f2739775189f3f0f7caf49b74775fce4102c28c5 && git checkout f2739775189f3f0f7caf49b74775fce4102c28c5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6e180163def1d31004905e4d6aac2d413571fb8e 70eca6c17eb6dbe65bd6b4ecf4d78a3e89032b39 && git checkout -B drift-repro 6e180163def1d31004905e4d6aac2d413571fb8e && git merge --no-ff 70eca6c17eb6dbe65bd6b4ecf4d78a3e89032b39

node scripts/docs-audit/affected-docs.mjs --json 6e180163def1d31004905e4d6aac2d413571fb8e

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Test Core (6/6) is red on e238774dd: this PR's, a test-rig double, fix round under way · domain:cli seat 2 · session session_018t6a5rvqPRvEDMCVVW9kYT · 2026-10-17T00:54Z

  • What fails. packages/runtime/src/domains/data-write-request-gate.test.ts, 12 tests in its [#23574] exposure blocks (job 117261705698). On the RestServer door, an exposure-denied object answers 400 VALIDATION_FAILED where 405 OBJECT_API_METHOD_NOT_ALLOWED / 404 OBJECT_API_DISABLED is pinned, and the two-door parity rows then differ.
  • Why. The rig's real protocol has no metadata store, so the rig answers getMetaItems from the registry by override but not getMetaItem. The gate's by-name read finds nothing there and fails open, by design. It is the same class as the three packages/rest doubles in ruling 6226615729 (Q1 A). Those rulings' "existing exposure tests" grep covered packages/rest only, and this rig is outside it.
  • The fix, ruled on 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. Every rig of this class also answers the by-name read with the definition its list holds, with assertions unchanged. The dev runs packages/runtime's full local project and @objectstack/http-conformance before pushing. No production change.

Generated by Claude Code

… read from the registry, beside the list

The rig's real protocol has no metadata store, so it already answered
getMetaItems from the registry; the REST exposure gate now reads its one
object by name, and that read threw here, so the gate failed open and
twelve exposure rows read as allowed objects. getMetaItem for type object
now answers the same registry definition the list holds; other types keep
the real read. No assertion changes.

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24031 · domain:cli seat 2 · os-tesla · session session_018t6a5rvqPRvEDMCVVW9kYT · 2026-10-17T01:39Z · reviewed head 70eca6c17eb6dbe65bd6b4ecf4d78a3e89032b39, against GitHub and the branch, not against the report's prose. Holder of claim 6225541511, order correction 6225613058, rulings 6226615729 and 6227635439; reports on #24031.

  1. Shape.
    • Draft, base main, assignee os-tesla. Line 1 is Fixes #24031; Clause-②: no is in the body and the changeset.
    • 9 files, +450 / −12.
    • check-governed-merges --pr 24093: NOT governed; 462 changed lines.
    • git merge-tree is clean against origin/main.
  2. The change, read by this seat against the amended surface.
    • enforceApiAccess reads the one object it judges, through getMetaItem({ type: 'object', name }), via loadObjectItem beside loadObjectItems.
    • A thrown read fails open and logs (元数据不可解析时的 fail-open 残余风险评估(api-exposure / rest-server) #3545). No item means no policy and no log. A protocol with no by-name read is asked the list. Nothing is cached.
    • Four test files change only their protocol doubles (Q1 A and its extension). This seat counted 0 changed lines matching expect( / it( / describe( / toBe / toEqual across them:
      • rest.test.ts;
      • rest-bulk-path-object.test.ts;
      • rest-export-permission-gate.test.ts;
      • the runtime rig data-write-request-gate.test.ts.
    • The runtime rig was outside the first rounds' "existing exposure tests" grep, which named packages/rest only. Test Core (6/6) caught it on e238774dd (diagnosis 6227635439). The dev's census of every non-rest test that constructs RestServer found no other member of the class.
  3. Review of record: this record. The gate's answers are unchanged except the one class the changeset declares (Q2 B): a denied operation on a disabled package's object gets the exposure verdict before OBJECT_PACKAGE_DISABLED. Every refusal stays a refusal, and no accept set moves.
  4. Changeset: one, @objectstack/rest patch. It gives the latency, the Q2 FROM / TO and Clause-②: no.
  5. Gates, pinned to the head. Check-runs on 70eca6c17, read at this stamp, latest run per name: 40 names, 32 success, 8 skipped, none failed, none running.
    • The successes include Build Core, Test Core 1–6/6, TypeScript Type Check, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard.
    • The skips are roster names.
    • The dev's local readings: 75 derived, 75 run, all exit 0. @objectstack/rest local suite: 290 files, 5990 passed. @objectstack/runtime local project: 405 files, 6175 passed. @objectstack/http-conformance: 150 passed.
    • The ablation (loadObjectItems put back) reddens 17 rest rows, the zero-read and by-name ones and the declared Q2 row, while every verdict row stays green under both reads.
    • Measured on the showcase: object-list reads per /data request 1 → 0, and 33 list calls 2421 ms → 647 ms (−73 %).
  6. Accepted deviations:
    • the generated engine-double-contract.pinned.json entry, declared on [PM seat] domain:spec — ⏳ vacant #6017 (6226622031);
    • the base worktree for the /meta door readings, removed afterwards;
    • the assumption-1 sweep taken before the merge (the merge touched none of the three reads);
    • no main merge in the last round (merge-tree clean).

Filed from it: #24097 (GET /meta/object/:name serves a disabled package's object while the list omits it, #7557).

After it lands: #23977 (pm:blocked on this card) is unlocked for its re-measure.

Landing: pr_ready + automerge_enable through the relay, after this record lands. The card closes on the merge (Fixes).


Generated by Claude Code

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

2 participants