Skip to content

perf(metadata-protocol): one GET /api/v1/data read makes one sys_metadata read of its object, not five - #24391

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24371-data-read-overlay-row-once
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-24371-data-read-overlay-row-once

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #24371

Clause-②: no (no accept set or public surface moves: the same answer with fewer reads; owes the seat's review of record at TIER_DEFAULT before the queue)

What changed

Every GET /api/v1/data/OBJECT goes through the API-exposure gate, which reads the object by name (getMetaItem). That read sent three active-row lookups to the store one at a time, five round trips in all:

  1. the package-less binding (packagelessAddress), one read with no limit;
  2. and 3. the served row (findServedOverlayRow), one findOne per spelling (object, then objects);
  3. and 5. the rows the lock is chosen from (overlayLockLayerAt), one read per spelling with no limit.

Now readMetaItem reads the item's ACTIVE rows once, for both spellings (readItemActiveRows: type IN (canonical, other), name, state = 'active', which uses the (type, name, …) overlay index). Each lookup then picks its rows from that read using the same where it used to send the store (rowMatchesOverlayWhere). The version token reuses the package binding the read already selected, so it no longer repeats lookup 1. The rows are read once per call and never kept, so the next request sees a metadata write.

Only packages/metadata-protocol/src/protocol.ts changes at runtime. The other callers of the three lookups (getMetaItemLayered, the _lock gate, the save and delete doors) are unchanged: each still reads the store itself.

Trace (M1): measured, before any fix

A real backend (pnpm dev:todo, SQLite, knex query log) and one signed-in GET /api/v1/data/todo_task on base 6a49efb9 logged exactly the five statements the card names, in this order. All five come from the single getMetaItem call that enforceApiAccess makes (packages/rest/src/rest-server.ts):

select * from sys_metadata where type = ? and name = ? and state = ?            ['object','todo_task','active']   packagelessAddress
select … where type = ? and name = ? and state = ? limit ?                      ['object','todo_task','active',1] findServedOverlayRow, canonical
select … where type = ? and name = ? and state = ? limit ?                      ['objects','todo_task','active',1] findServedOverlayRow, other spelling
select * from sys_metadata where type = ? and name = ? and state = ?            ['object','todo_task','active']   overlayLockLayerAt, canonical
select * from sys_metadata where type = ? and name = ? and state = ?            ['objects','todo_task','active']  overlayLockLayerAt, other spelling

findData itself made no sys_metadata read: the registry already holds the definition. No other sys_metadata read happens per request. The sys_metadata_activation read in the session prelude is a different table and belongs to #10757.

H1 was confirmed, with one correction. The fifth read is not a second caller. It is packagelessAddress, the package-less binding the served-row lookup is given.

After the fix, the same request logs one statement:

select * from sys_metadata where type in (?, ?) and name = ? and state = ?      ['object','objects','todo_task','active']

Latency (M2): measured at 50 ms RTT

I ran Postgres 16 locally behind a TCP delay proxy that adds 25 ms each way. The todo app ran on a database seeded fresh for each leg. Each leg did 3 warm-up reads, then 15 timed GET /api/v1/data/todo_task as the signed-in admin. The before leg ran protocol.ts from base 6a49efb9; the after leg ran this branch.

median range sys_metadata statements per request
before 551 ms 540–646 ms 5 (15 of 15 requests)
after 340 ms 334–573 ms 1 (15 of 15 requests)

The 211 ms saved is about four round trips. With ids and timestamps masked, the response bodies of the two legs are identical (8 records). These are wall times on a shared box.

Pins

  • Count, at the door. packages/rest/src/rest-data-read-one-overlay-read.test.ts uses the real RestServer, the real protocol, the real SchemaRegistry and the real findData, over an engine double that records every sys_metadata read:

    • a list and a get of a code-defined object make exactly ONE read, type: { $in: ['object', 'objects'] };
    • an object stored in the environment makes that read plus the ONE head read behind version;
    • a stored enable whitelist, stored under either spelling, still refuses a list 405;
    • a row edited between two requests is judged by the second.
  • Answers, differential. packages/metadata-protocol/src/protocol.item-active-rows-one-read.test.ts compares each lookup made from the one read with the store's own answer. It covers 76 arrangements of object rows and 584 of a bundled type (email_template): both spellings, the package-unbound row and two packages, canonical and non-canonical member, three lock states, two store orders. Every address is checked. Each lookup returns the same answer either way.

  • Ablation (one, on the behaviour the pins test). I swapped activeRows = await this.readItemActiveRows(…) for an undefined read through scripts/ablation-replace.mjs. On disk the anchor count went 1 → 0 and the marker count 0 → 1. ablation-dist-preflight then reported the marker present in 2 built files. The count rows went red (5 failed, 39 passed across this file and rest-exposure-gate-one-object-read.test.ts):

    • code object: 5 reads instead of 1;
    • stored object: 4 instead of 2;
    • the two 405 rows failed on their read count after their status and code assertions passed.

    Every answer row stayed green. Restore leg: git hash-object matches the HEAD blob, --absent preflight passed with a clean tree, and the same run gave 44 passed.

Pin sweep: test doubles that answer where by equality only

The single read sends type: { $in: [...] }. Many hand-written sys_metadata doubles compared every where value by plain equality, so the $in value matched nothing and the stored row silently vanished. That made 27 metadata-protocol files fail (5803 tests), plus 8 in rest, 11 in objectql and 1 in runtime.

Each double now compares $in membership as a driver does. It still refuses (throws on) every other operator it refused before. Every real driver (sql, memory, mongodb) already supports $in, and findEnvironmentRows already sends $nin.

packages/objectql/src/protocol-meta.test.ts had pins on the old read sequence: the findOne order ['app', 'apps', 'app'], and overlay rows served through findOne mocks. These were flipped to the new contract: one find with $in, then the one head findOne. The flipped rows carry weight; they are the ones the ablation would turn red.

Three doubles (protocol.save-flow-canonicalization, protocol.runtime-authoring-gate, objectql protocol-publish-rollback) answered find without filtering by name. They now filter by name, because the served row now comes from find.

Verification record (final head f95842334, merged with origin/main at 5da2e888)

  • Build: pnpm build (76 of 76 tasks), then the touched closure.

  • Typecheck, exit 0 with 0 errors for each of @objectstack/metadata-protocol, @objectstack/rest, @objectstack/objectql and @objectstack/runtime. The rest and metadata-protocol test layers compile; for rest, check:test-typecheck passed with 0 debt.

  • Tests (exit 0):

    package tests
    metadata-protocol 20655 passed
    rest 6051 passed
    objectql 8301 passed
    runtime 6608 passed
    plugin-approvals 1108
    plugin-security 3736
    service-automation 2533
    service-package 125
    client 671
    cloud-connection 614
    cli --project unit 4609

    These are every package whose tests construct the protocol over a double. The two rest files and one objectql file that the merge brought in were re-run on the merged tree (21 and 107 passed).

  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths derived 79 commands on f95842334. All 79 were run with the exit code captured before any pipe, and all exited 0. --ran reports: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero; every line carries its exit code). check:engine-double-contract asked for the two new doubles to be recorded, and scripts/engine-double-contract.pinned.json gained two rows from --write.

  • Not run locally: test:repo and the repo-wide lint, which CI owns. The cli integration tier was not run, as declared for CI: no spawn entry was touched.

Files outside domain:engine

The runtime change stays in packages/metadata-protocol/src/protocol.ts. The sweep touches test files only, in packages/rest/src (9, one of them the new count pin), packages/objectql/src (11) and packages/runtime/src/domains (1), plus the ledger scripts/engine-double-contract.pinned.json.

Acceptance notes

  • The card's bar is at most one sys_metadata read per data read. An object with no stored row (every code-defined object, which covers all of the console's opening reads that the card measured) meets it: 5 → 1. An object with a stored environment row still makes a second read: the head read behind getMetaItem's version. It goes through SysMetadataRepository.headAt, the repository's one write-address resolution, whose own contract forbids re-deriving it elsewhere. Such an object is now at 2 (measured live: 3 before the version change, 2 in the count pin), down from 5 by the trace on base. The base count for a stored-row object was traced, not measured live.
  • Lookups the data path does not reach still issue their own reads, unchanged: getMetaItemLayered, the _lock gate on writes, the save and delete doors.
  • Case-exact selection (record F1). The one read's rows are selected in JavaScript with exact string comparison. On a SQL store whose column collation is case-insensitive (MySQL's default _ci), a by-name read whose name, ?package= or bundle member differs from the stored row only by letter case used to be served that row and its lock. It is now not served it. Every grammatical address is unaffected, because the save and publish doors refuse a name outside METADATA_ITEM_NAME_PATTERN (lowercase snake_case). The registry, the metadata service and the /data registry gate were already case-exact, as SQLite and Postgres always were.

Generated by Claude Code

… reads from one store read of both spellings

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe2h3xpvRmGq9TqyCTV9JC
…age-less binding the read already selected

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe2h3xpvRmGq9TqyCTV9JC
…'s both-spellings read

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe2h3xpvRmGq9TqyCTV9JC
…tions against the store's own answers

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe2h3xpvRmGq9TqyCTV9JC
…per by-name read

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe2h3xpvRmGq9TqyCTV9JC
…icate; the double ledger records both new pins

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 9 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/api/index.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/automation/flows.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/data-modeling/drivers.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/data-modeling/objects.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/deployment/cli.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/deployment/environment-variables.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/deployment/validating-metadata.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/kernel/cluster.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/kernel/contracts/metadata-service.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/kernel/services-checklist.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/permissions/authorization.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/permissions/permission-sets.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/plugins/packages.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))

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

  • content/docs/releases/implementation-status.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/releases/v17/17-3.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/releases/v17/17-5.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/releases/v17/17-6.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/releases/v17/17-7.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))
  • content/docs/releases/v17/index.mdx (via sys_metadata (literal, a string literal in findServedOverlayRow; a string literal in packagelessAddress; a string literal in readItemActiveRows; a string literal in readMetaItem))

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
  • 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 — 11 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 cf16a0d18aedbe58e9bfc0d7bc140fa660465eaf → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 cf16a0d18aedbe58e9bfc0d7bc140fa660465eaf → 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

Contract review

Served-tier: TIER_DEFAULT
Head-sha: f95842334f57bfe991269086bb23a4db0363624c
Local-runs: none

Read for this record: card #24371's body and all four comments (triage 6252555847, claim 6252699680, dispatch 6252724947, os-dev-report 6254423678, read as claims); the PR body; all 48 paths of the net diff against the merge-base 5da2e88818a0, read with git diff / git show on fetched refs, nothing checked out; and the 40 check-runs on this head. The code read beyond the diff: every caller of readMetaItem / getMetaItem; findEnvironmentRow / findEnvironmentRows; resolveOverlayLockLayer / strictestRowLockDocument (item-lock.ts); overlayMemberWhere and the overlay index key (migrations/overlay-index.ts); storedHeadAt / readVersionToken; SysMetadataRepository.headAt / resolveWriteHead; rest-server.ts enforceApiAccess; the engine's find / findOne; and the filter compilers of driver-sql (with driver-turso and driver-sqlite-wasm, which extend it), driver-memory and driver-mongodb.

① Derived judgments

The change. At base, readMetaItem sent each active-row selection to the store separately. These were packagelessReadAddress (a find on the canonical spelling), findServedOverlayRow (one findOne per candidate) and overlayLockLayerAt (a canonical find, plus the other spelling when that was empty). For a code-defined object that is 5 reads, which matches the card. At head, readItemActiveRows makes ONE findEnvironmentRows read: type: { $in: [type, other] }, name, state: 'active'. Each of the three selections then filters that read with the same where object it used to send, through rowMatchesOverlayWhere. The rows live only in readMetaItem locals. Only readMetaItem passes them on: grep of every call site of the four private methods shows that getMetaItemLayered, the _lock gate (otherSpelling: false), and the save and delete doors still read the store themselves.

Selection by the same where: holds. Every where the three selections build is equality-only, with primitive values: type, name, state, package_id (a string or null) and bundle_member (from overlayMemberWhere: a string or null, or absent for an undiscriminated type). None of them carries an undefined value or an operator. So (stored[key] ?? null) === value is exactly the store's = / IS NULL on a case-exact store. The one read is a superset of each selection's rows:

  • other is the same formula (PLURAL_TO_SINGULAR[t] ?? SINGULAR_TO_PLURAL[t]) in readItemActiveRows, findServedOverlayRow and overlayLockLayerAt.
  • packagelessAddress's PLURAL_TO_SINGULAR[t] ?? t is always one of {t, other}.

So every spelling pair the two maps know is covered, not only object / objects. The pins exercise object / objects, email_template's two spellings with both members, app / apps and hook / hooks.

Drafts are untouched by the change:

  • activeRows is read only when readState === 'active';
  • findServedOverlayRow selects from it only when args.state === 'active';
  • packagelessAddress selects from it only when state === 'active';
  • the preview arm and the strict draft read still read the store.

Each answer, case by case:

  • The overlay lock layer. resolveOverlayLockLayer still takes the canonical rows, and the other spelling only when there are none. strictestRowLockDocument takes the strictest lock across them and orders by rank, then package_id, then id. So the lock and its prose do not depend on row order, and the selection from the one read equals each per-spelling read. This is the strictness the _lock gate's own call judges by.
  • The package-less binding. packagelessAddress reduces the rows to a sorted set of bindings, so the order does not matter. contentPackageId stays request.packageId || (binding ?? undefined), which is exactly the old packagelessReadAddress. That includes the empty-string packageId case and the draft state (no binding read).
  • The version token a save accepts. readVersionToken used to re-run packagelessAddress(singular, name, member) at active. It now receives packagelessBinding, which is the same selection over the same rows. It is passed only for an active read naming no package, and readVersionToken still prefers request.packageId whenever that is != null. The head itself still comes from storedHeadAt → headAt → resolveWriteHead: at active, one findEnvironmentRow at whereFor(binding, member). So the token is still the single derivation the save door judges against. The only difference is that it is now consistent with the one snapshot rather than a later re-read.
  • A stored row vs a code-only object.
    • Code-only: 1 read. storedRowServed is false, so no version read.
    • Stored row: 1 read plus headAt.
    • The steps after that (resolveRowlessExpandedView, which returns at once for every type but view; MetadataService; registry; the artifact lock) make no sys_metadata read, and the dev's live trace logged none.
  • A disabled package. getMetaItem asks isObjectOfDisabledPackage before any read, and isItemOfDisabledPackage of the same served item after it. Unchanged.
  • An organization's row vs another's. The family has no organization column. Legacy organization rows are excluded by id, equally, by findEnvironmentRow (a findOne, then a re-find when the row is legacy) and by findEnvironmentRows (a filter). So the row set is the same.
  • A failed read. One try/catch with rethrowUnlessMetadataStoreUnprovisioned. An unprovisioned store gives [], so every selection is empty, which is what each old catch answered. Any other failure propagates, as before.

Cache: none. activeRows and packagelessBinding are locals of one call. No instance field and no module binding is added. The rest pin's freshness row edits a row between two requests, and the second request judges the edited row.

type $in per driver: equivalent to two equality reads.

  • driver-sql compiles $in to whereIn against the same column and collation that = uses. driver-turso and driver-sqlite-wasm extend SqlDriver.
  • driver-memory hands $in to mingo.
  • driver-mongodb uses $in natively.
  • The list holds no NULL.
  • $in on sys_metadata already exists at base (unmanagedImportCollisions, name: { $in }), so no driver is asked for anything new.

Ordering and case each have a residue. Neither is a contract breach.

  • Ordering. The SQL findOne imposes no ORDER BY (singleRowLookup), and neither does an unlimited find. So the order differs only where the contract is already "any row": the package-agnostic candidate when two or more packages hold the item (ADR-0048 §3.3 best-effort; the docblock's "driver-order coin toss"), and duplicate keys under MySQL's non-unique fallback index. Before and after, that row is the store's pick. The differential double uses one insertion order for both verbs, so it cannot see a difference in query-plan order. Non-blocking.

  • Case: finding F1, non-blocking. The JavaScript re-match compares exact strings. driver-sql sets no column collation, and MySQL's default _ci collation compares = case- and accent-insensitively. So on MySQL, a by-name read whose name, ?package= or bundle member differs from the stored row only by letter case used to be served that row and its lock, and is not served it now. This is benign:

    • refuseUngrammaticalMetaItemName (METADATA_ITEM_NAME_PATTERN, lowercase snake_case) refuses such names at save and publish, so a grammatical address never differs from its row;
    • the registry, the MetadataService, the list merge and the /data registry gate are all case-exact;
    • SQLite and Postgres, the stores CI runs and the card measured, always answered this way;
    • an off-case /data/:object 404s at assertObjectRegistered, so nothing is exposed;
    • the save door refuses the name 400, so no read/write disagreement opens.

    The PR makes MySQL agree with the case-exact contract. It is undisclosed, though. The changeset's "every refusal stay the same" and the rowMatchesOverlayWhere docblock's "one the store would answer for where" are true only for a case-exact store. Recommended: one clause in the PR's acceptance notes. The release-owned changeset sentence may stay.

5 → 1 count: holds for the card's case.

  • Live, as claims: the SQLite knex log, 5 statements → 1; and Postgres at 50 ms RTT, 551 → 340 ms median, 15 of 15 requests at 1 statement.
  • By reading the code, it holds too.
  • The count pin is at the door (rest-data-read-one-overlay-read.test.ts): the real RestServer, protocol, SchemaRegistry and findData. A list and a get of a code object make exactly one find.
  • The stored-row case's second read (headAt) is honestly reported. It appears in the PR body's acceptance notes, the changeset's "Unchanged" bullet, the pin's ['find', 'findOne'] assertion and open question 1. The base count for a stored row was traced, not measured, and the PR says so.

The flipped protocol-meta.test.ts pin: re-aimed to the truth, not weakened.

  • Each removed findOne sequence is replaced by an exact find assertion, type: { $in: [canonical, other] } with the isSystem context. "Overlay row from DB" adds toHaveBeenCalledTimes(1).
  • "Alternate type name" keeps the head read's address, package_id: null, and its version: null.
  • The org-scope row now checks find and findOne calls alike.
  • The outage and unprovisioned rows reject both verbs and keep their assertions.

The new differential pin (protocol.item-active-rows-one-read.test.ts) compares the binding, the served row and the lock from the one read against the store's own answer, at every address. It covers 76 object arrangements and 584 email_template arrangements, and a floor row guards the matrix size.

The doubles: 42 sampled, every one.

  • 38 got the shared whereValueMatches helper. The 4 objectql memory-driver doubles (default-record-page-save-door, both environment-overlay-*, registry-field-type-refused-at-door) got the same $in arm inline.
  • Each adds $in membership and nothing else. The arm applies to any key, as a real driver does. It is not looser than any driver.
  • The throwing doubles (packageless-save-address, save-door-lock-family, every $-combinator thrower) still refuse every other operator and combinator. The non-throwing doubles still answer no match for any other operator object, as before.
  • Three doubles became stricter: save-flow-canonicalization, runtime-authoring-gate and objectql protocol-publish-rollback now filter find by name. save-union-issues's find now returns its seed rows instead of an unconditional [].
  • Nit: the arm keys on the presence of $in, so a mixed map such as { $in, $ne } would be read as $in alone, where the two throwing doubles used to refuse it. No producer emits that shape.

Ledger. scripts/engine-double-contract.pinned.json gains two pinned rows: the two new doubles' findOne, which reaches assertEngineFindOnePredicate, an existing export. They were written by --write; no exemption was added.

Gates (the check-runs on this head, read at 20:20Z). All 40 are complete: 34 success, 6 skipped, none red or pending.

  • The five required contexts all concluded success: Lint & Repo Gates, TypeScript Type Check, Test Core (all six shards), Build Core and Governed Surface Queue Guard.
  • The six skipped are all full-battery or opt-in lanes: Console Pin Gate, Temporal Conformance (live PG + MySQL), Build Docs, Dogfood Verify CLI, the dogfood shard matrix and Packed-tarball smoke. So no live MySQL ran on this PR, which is why F1 is a reading, not a measurement.

② Semver level

patch is right. It is a performance fix in which only private members move: the new readItemActiveRows, the non-exported rowMatchesOverlayWhere, and an optional trailing parameter on four private methods. No export, schema, route, query parameter or response shape changes. @objectstack/metadata-protocol versions with its fixed group.

Clause-②: no on the PR body is truthful. It parses as no followed by reasoning (no arm). The changeset carries a bare Clause-②: no. No published accept set or public surface widens or narrows:

  • every grammatical address gets the same answer on every store;
  • the query the engine receives changes shape, but $in on sys_metadata has a precedent at base;
  • F1 moves only off-grammar input on one dialect's collation, and that is not a published accept set.

The prose face is accurate, apart from F1's scope. That includes "Every real driver (sql, memory, mongodb) already supports $in", which is true, as is the count and latency wording, which the PR body labels as a measurement on a shared box.

③ Boundary flags

Deviations:

  • 21 test files and the ledger outside the claim's list (rest 9, objectql 11, runtime 1; scripts 1). They are all test-only, and they are the forced consequence of shape A: an equality-only double cannot match type $in. The claim allowed reporting them. Accepted. Owed by the seat, not by this review: the lane declaration for packages/rest, packages/objectql and packages/runtime before the queue.
  • The ledger rows. Additive pinned rows, gate green. Accepted.
  • Postgres measured from /tmp. Declared: the harness keeps the scratchpad root-only and Postgres refuses to run as root. Stopped and deleted; nothing reached the repo. Accepted.
  • 10 pushes. All to its own branch, one per commit under the WIP rule, never forced. Accepted.
  • PR footer. In the AGENTS.md session-URL form. Accepted.
  • A count slip. The report names 22 metadata-protocol doubles; the file list holds 23 (view-container-runtime-expansion included). Immaterial.

Open question 1 (the stored-row second read): A, accepted.

  • B would derive the head a second time beside resolveWriteHead, which storedHeadAt's docblock forbids ("never a second derivation"). It would also reach into put's lock path.
  • C adds a protocol face.
  • The card's measured population (code-defined objects, the console open's reads) is at 1. A stored-row object is at 2, down from 5, and disclosed.
  • Escalated to the seat: whether Fixes #24371 closes the card on that residual. If a stored-object measure ever pulls for it, B is its own card.

Open question 2 (the shape of the single read): A, accepted.

  • B is two statements on one connection, so it fails the card's count.
  • C either outlives the call, which the dispatch forbids, or reads the registry, which holds no stored overlays.
  • A stays one statement on the (type, name, …) overlay index, and the double sweep keeps every double strict.

out_of_scope_findings (doubles that answered find without a name filter): judged correctly. All three are fixed in this PR. They are test-only and no public door answered wrongly, so "noted, not filed" stands.

F1 (this review): an acceptance note, not a card. It is a move toward the case-exact contract, and the defect class does not apply.

Implemented-by: claude/issue-24371-data-read-overlay-row-once
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T20:24Z · ACCEPT

Head: f95842334f (the fix, the double sweep, the pins, then a merge of main). Card #24371, claim 6252699680, dispatch 6252724947. Dev report: 6254423678.

Item Reading
Shape Draft PR → main. The body opens Fixes #24371. 48 files, +839 / −91. Not governed (check-governed-merges.mjs --pr 24391); 930 changed lines, under the human-merge threshold.
Scope One runtime file, packages/metadata-protocol/src/protocol.ts: readMetaItem reads the item's active rows once (readItemActiveRows, type $in both spellings), and the three lookups select from that read by the where each used to send (rowMatchesOverlayWhere). Two new pins, the re-aimed protocol-meta.test.ts, two gate-written ledger rows, and about 40 test doubles taught $in. The doubles' files in packages/rest and packages/runtime are declared to domain:cli (6254472332, 6254478160); the objectql ones are in this lane. One changeset, patch.
Seat's own read At the head, readItemActiveRows is the one read, with type: { $in: [type, other] }, name, state: 'active'. findServedOverlayRow and overlayLockLayerAt select from activeRows when it is passed. Only readMetaItem reads into it, and it is a local of that call.
Measured The trace: all five reads came from the one getMetaItem call the /data exposure gate makes. On Postgres behind a 50 ms round trip, a GET /api/v1/data/OBJECT went from a median of 551 ms with 5 sys_metadata statements to 340 ms with 1, with identical bodies once ids and timestamps are masked.
Review face Clause-②: no. The seat's review of record at TIER_DEFAULT, 6254708368 on this head, is PASS. The seat verified its central claims at the head (the row above).
Declaration Clause-②: no on the PR body and the changeset; nothing published widens or narrows. The changeset is patch: only private members move.
Pins and ablation The door pin counts exactly one find for a list and a get of a code object, find + findOne for a stored object, and checks that an edited row is seen by the next request. The differential pin checks every lookup from the one read against the store's own answer, over 76 object and 584 email_template row arrangements. Ablating the one read turned the count rows red; the restore read back as the HEAD blob.
Gates Dev at f95842334f: 79 derived, 79 run, 0 NOT MEASURED. Typecheck exit 0 for metadata-protocol, rest, objectql and runtime.
CI At this read: 40 check-runs on f95842334f, 34 success, 6 skipped, 0 failed. The skips are the rostered path and opt-in skips. Test Core, every Type Check lane, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard are green.

Disposition:

  • open_questions:
    1. The stored-row second read: A, leave it, as record ③ answers it. That read is the version head through SysMetadataRepository.headAt, the one write-address resolution whose contract forbids a second derivation. A stored-row object is at 2 reads, down from 5, and the PR discloses it. The card's wanted is "one read of an object's served overlay row", which holds for every object, and its measured case (the console open's code-defined objects) is at 1. So Fixes #24371 closes the card. If a stored-object measure ever pulls for threading the head through the one read, that is its own card.
    2. The single read's shape: A, one $in query. Two parallel reads are two statements on one connection, and a memo would outlive the call.
  • deviations: all accepted, as record ③ judges them: the test-only files outside the claim (forced by the $in shape; each double gained a $in arm and nothing looser), the ledger rows, Postgres measured from /tmp, the per-commit pushes and the footer form.
  • Record F1 (case-exact selection on a case-insensitive SQL collation): an acceptance note, now in the PR body. It moves only names the save doors already refuse, toward the case-exact contract every other layer keeps. Not a card.
  • out_of_scope_findings: the three loose doubles are fixed in this PR. Nothing to file.

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Every check on this head is green or a rostered skip. The seat's body edit re-runs the body-triggered guards. Fixes #24371 then closes the card. #24360 (the engine copy strings, waiting on protocol.ts) can be claimed after this lands.


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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

data reads: every GET /api/v1/data read makes five serial sys_metadata reads of the target object's own active row (about 250 ms at 50 ms RTT)

2 participants