Skip to content

fix(metadata-protocol): the object door never lets a container's expansion displace a stored row of the same name (#21510) - #21557

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21510-stored-row-wins-list
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21510-stored-row-wins-list

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21510

Clause-②: no

What this changes

Triage's ruling on the card (comment 5964342087): the stored row wins on both doors. ADR-0005 keys an overlay by its own name, so a row stored under exactly a name is the sanctioned override for it. An expansion is derived from its container, so it fills only names that have no row of their own. The by-name read has applied that rule since PR #21508. This PR makes the object door's list read apply it too, through the same predicate.

  • The defect. readFlattenedMetaItems (the list read behind GET /api/v1/meta/view?object=OBJECT) upserted every name a stored view container expands into its answer by bare name (byName.set(vi.name, vi)). That ran after the package-aware merge had seated the stored rows, so it replaced a stored row of the same name. The by-name read (getMetaItem) answered that row. The two doors disagreed, against metadata: a view container with a bare list on another package's object silently replaces that object's packaged default view on GET /meta/view?object= — while the by-name read still serves the original #21334's ruling ("the object door and the by-name read answer the same row").
  • One predicate, read by both doors. The by-name read's test was inline in resolveRowlessExpandedView: a records.some(...) that compared each row's name with request.name, over the rows readActiveOverlayRows selected for the caller. It is factored out unchanged as namesWithOwnStoredRow(records), which returns the names that have a stored row of their own in that selection. resolveRowlessExpandedView now asks it in place of the inline test, with the same answer for every input: a non-string row name matched no request name before and is left out of the set now. The list read asks it over its own records, the same selection, and skips an expansion whose name is in the set. There is no second test.
  • What still holds. A name the container expands that has no row of its own is still listed, and on both doors it still replaces a packaged view of the same name (metadata-protocol: a view that a runtime view container expands is listed by GET /meta/view?object= but answers nothing by name on an environment-scoped kernel or for an org-scoped container — the by-name read expands no container #21442's tenant-overlay case). The predicate reads the caller's own rows, so a row stored for one organization wins for that organization only, and every other caller still gets the expansion on both doors. The by-name read, its layers, history and diff answer as before. Nothing is persisted or registered, and no response shape gains or loses a key.
  • Region. The edits are the list read's expansion pass, the new private method, and the one-line move in resolveRowlessExpandedView (plus its docblock). hydrateExpandedViewItems, the save door and the data door's existence gate are not touched.

Measured, in-process at the protocol (the #21334 showcase harness)

The dev's setup, as the card describes it: a stored overlay of the showcase's own showcase_task container, with a list (label FromContainer) and a listViews.in_progress member (label FromContainer In Progress), plus a stored ViewItem row named showcase_task.default (label ByNameRow), written through the save door.

kernel scope of both rows write order before: object door / by-name, showcase_task.default after: both doors control showcase_task.in_progress, before and after
env_local environment-wide container, then row FromContainer / ByNameRow ByNameRow the expansion, both doors
env_local environment-wide row, then container FromContainer / ByNameRow ByNameRow the expansion, both doors
env_local organization-scoped either order FromContainer / ByNameRow ByNameRow the expansion, both doors
unscoped environment-wide either order FromContainer / ByNameRow ByNameRow the expansion, both doors
unscoped organization-scoped either order FromContainer / ByNameRow ByNameRow the expansion, both doors

"Before" is origin/main at 24dc7c1134, read with a throwaway test that was deleted afterwards. "After" is this branch.

The public input (PM mechanism assumption 4). In every cell above, the save door accepts saveMetaItem with type view, the name showcase_task.default and a body whose name is showcase_task.default, and it stores the row under that name. Since #21470 the save door judges the body name against the save name, and these are equal. The pins assert that the row was stored.

Tests

In packages/metadata-protocol/src/view-container-runtime-expansion.test.ts, nested in the #21334 block to reuse its faithful-registry harness, there are 10 new cases (the file goes from 119 to 129):

  • 8 cases: both kernels, environment-wide and organization-scoped, both write orders. In each, the stored row answers showcase_task.default on both doors, and the by-name item equals the listed one (_diagnostics excluded). The row's name keeps its own history (every event's ref.name is the row's) and its own diff (name is the row's), never the container's. The control, showcase_task.in_progress, answers the expansion on both doors. Every name the object door lists answers the same item by name.
  • 2 cases, one per kernel: the predicate reads the caller's own rows. The container is environment-wide and the row is stored for org_acme. org_acme gets the row on both doors. A caller with no organization and org_globex get the container's expansion on both doors.

Results:

  • At the final head 6a41000f1e, the full package suite (vitest run) gives 206 files passed / 3 skipped, 3173 tests passed / 19 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck is clean, and tsc --listFiles includes the edited test file.
  • Downstream, a narrowed and declared sample against the rebuilt dist/ (it carries namesWithOwnStoredRow): the test files that read the view object door through the real protocol. @objectstack/objectql gives 5 files / 71 tests and @objectstack/rest gives 1 file / 24 tests, all green. The rest of those packages, and the dogfood suites, are CI's.

Reverse verification

Each mutation was made from committed state (6a41000f1e) through scripts/ablation-replace.mjs, inside a script with an EXIT/INT/TERM restore trap. After each leg, the restore was proven: blob f1622d5bdde2 equals HEAD, and git diff HEAD is empty. The subject resolves through relative source imports (./index.js), so no dist/ leg applies. The predicted direction was red, and every leg went red.

  • A1, the list read's call off (… && false) continue;): 10 failed / 119 passed. All 10 new cases fail with expected 'FromContainer' to be 'ByNameRow', the card's defect.
  • A2, the predicate's body off (no name is ever added): 10 failed / 119 passed, the same 10. The list read fails first in each case.
  • A3, only the by-name family's call off (in resolveRowlessExpandedView): 8 failed / 121 passed. Both doors still answer the row, but history now delegates to the container: every event names the row: expected false to be true. So the by-name family reads the same predicate and is pinned by it.

Gates

  • dispatch-gates --commands --repo objectstack-ai/objectstack at the final head d12a8f6256 derived 64 families, the same set as at 6a41000f1e. The PM's lead had 56; the changeset adds 8: check-adr-0087-registration ×2, check-empty-changeset ×2, release-rehearsal-clone --self-test, release-pending-publish --self-test, check:objectui-changeset and check:pm-changeset-deadline-census.
  • After the final commit, all 64 exited 0. pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, 44 package entry points with no dist/) in the first run at 6a41000f1e. By the run at d12a8f6256, those dist/ directories were present in this worktree (created at 06:27Z, while the first run's check:type-check-debt re-measure was running), and it measured 106 require entry points across 66 packages, which load.
  • --ran reconciliation at d12a8f6256: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
  • d12a8f6256 changes only the changeset, so the test, typecheck and ablation readings above (taken at 6a41000f1e) read the same protocol.ts and test file bytes.
  • Lint, narrowed: eslint --no-inline-config --format json over the 2 changed TypeScript files gives 2 files linted, 0 errors and 0 warnings. The config does no type-aware linting (no parserOptions.project; see the note at eslint.config.mjs line 328), so this diff cannot move a verdict on an untouched file. A repo-wide pnpm lint is CI's.

Acceptance notes

  • A container saved under one of its own expanded names (measured, reported to the seat as a finding, not fixed here). The probe: the save door accepts a container body { name: 'showcase_task.default', object: 'showcase_task', list: {...} } saved under showcase_task.default, and its bare list expands to that same name.
    • Before (the list read's call off, which is the origin/main list): the object door listed the self-expansion, and the by-name read answered the raw container. The doors disagreed.
    • After: the container row is the name's own row, so its expansion does not fill that name. The canonical-shape filter (ADR-0017) never enumerates a container, so the object door lists nothing under showcase_task.default, while the by-name read still answers the raw container. That is how every container's own name already behaves. The doors still disagree, now in a different way.
    • Both kernels were measured. The ruling's predicate gives this result. Neither door can answer a ViewItem for that name while the stored row is a container. The candidate fix is a save-door refusal, which is outside this card's surface. The changeset states the case.
  • Declaration bytes. dist/index.d.ts gains one private member line (private namesWithOwnStoredRow;). No public member or exported type changes.
  • Cost. The list read builds one Set of row names per call, over rows it already holds. There is no extra read.

Generated by Claude Code

claude added 3 commits October 3, 2026 05:48
…tored row of the same name

The object door (getMetaItems, readFlattenedMetaItems) upserted every
name a stored view container expands over the merged items, a stored
row of exactly that name included, while the by-name read answered the
row. Both doors now ask one predicate, namesWithOwnStoredRow, over the
rows they select for the caller: an expansion fills only a name with no
stored row of its own. The by-name read's predicate is factored out
unchanged; the list read now asks it.

Pins: the dev's setup (a stored overlay of showcase_task's container
plus a stored row named showcase_task.default) on both kernels, both
scopes and both write orders, with the row-less expanded name as the
control, and a row stored for one organization answering for that
organization only.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…and diff beside an expansion of that name

The by-name family (history, diff) asks the same own-row predicate the
list read now asks, so a reversal of that predicate is observable on
both doors.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…ded names

A view item saved under an expanded name is what the object door now
lists; a container stored under such a name is its own row too, so the
object door (which never lists a container) lists nothing there.

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

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (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
  • 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 6cf1154a65ffcae3fdfe607a5d221572157ee353 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 6cf1154a65ffcae3fdfe607a5d221572157ee353 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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