Skip to content

fix(metadata-protocol)!: one stored-metadata filter collector and search narrowing, owned by the door and called by the reader seam; cross-field and deep family reads refused - #21619

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21544-door-narrowing-export
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21544-door-narrowing-export

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21544
Clause-②: yes (narrowing)

The generic data door now owns the stored-metadata family's one filter-field collector and its one default-search narrowing. Both are exported module functions, and the door and the in-process reader-context seam (@objectstack/runtime) call the same two. The card's measure-first step found a door gap: two filter shapes read a family column at the generic data door without the family's refusal ever seeing them. Per the ruling, the door therefore adopts the stricter collector in this landing, and under the card's raise rule the card is p1. Measurement below.

The door-gap measurement (measured before any collector was chosen)

Measured at b610eabf72 with a composed kernel (ObjectQL, a default datasource, HTTP server, platform objects, auth, security, sharing, REST and the dispatcher) and the administrator signed in. The family credential was stored by the production writer (PUT /meta/datasource/NAME). Every request went through three doors: POST /api/v1/data/OBJECT/query (HTTP), the in-process door (protocol.findData) and the seam (serveStoredMetadataReadsThrough over the engine). Both family tables were covered, on driver-sqlite-wasm and on driver-memory.

shape at the door request (body of POST /api/v1/data/sys_metadata/query) column read answer before this PR
positive control: direct reference {"where":{"metadata":{"$ne":"zzz"}}} metadata 400 INVALID_FIELD (family refusal)
cross-field comparand {"where":{"type":"datasource","name":{"$ne":{"$field":"metadata"}}}} metadata 200, 1 row; the $eq twin answers 0 rows; type $lt $field metadata answers every row and $gt answers none (SQL). The family column is evaluated.
cross-field comparand same, with checksum (and previous_checksum / change_note on sys_metadata_history) the hash column / the note 200, partitioned the same way (SQL)
cross-field comparand in an aggregation filter {"groupBy":["type"],"aggregations":[{"function":"count","alias":"n","filter":{"name":{"$ne":{"$field":"metadata"}}}}]} metadata 200, n = 1 (the $eq twin gives n = 0)
direct predicate below the depth backstop the body filter {"metadata":{"$contains":"STORED_CREDENTIAL"}} wrapped in 33 nested one-armed $and levels metadata 200, the row for the stored credential, 0 rows for a wrong guess, the row for a prefix. A credential oracle on both drivers, both tables, over HTTP and in-process, in where and in an aggregation filter. At 32 levels the same filter is refused (control).
dotted key {"where":{"metadata.x":"zzz"}} none 400 INVALID_FIELD from the door's dotted-path verdict (param: where). Moot: never evaluated.
having having naming metadata / checksum, or { "$field": … } to one none 400 INVALID_FILTER: the engine judges every having name against the aggregated row's columns. Grouping by a family column is refused, and min / max of the textarea / text family columns is refused by the engine's aggregate-field-type door. Moot.

The two bold shapes are the door gap. The cross-field one is a SQL-driver reach. On driver-memory the reference is not resolved at all (see Acceptance notes). The depth one is a reach on both drivers.

What this changes

Public surface: @objectstack/metadata-protocol (additive, minor)

  • collectStoredMetadataFilterFields(object, query): string[] is the family's one filter-field collector. It returns every column a read query's filters read, across where, the engine's filter alias and each aggregations[i].filter. That means each key's head plus each cross-field { $field } comparand's head, at any depth: the walk is iterative and cycle-safe, with no depth backstop. Anything beneath an unrecognised $ key is read as a condition, and a FilterArray is lowered first. It is [] outside the family. It does not read having, whose names are the aggregated row's.
  • narrowStoredMetadataSearch(object, query, schema, wireSpelling?): string[] | undefined is the family's one default-search narrowing. It moved, unchanged in its answers, from the door's private method of the same name:
    • an explicit list naming the body or a hash column is refused under the caller's wire spelling;
    • a default search returns the narrowed field set for the caller to run as searchFields;
    • an emptied set is refused;
    • undefined means "run as is".
  • type StoredMetadataSearchSchema is the definition slice the narrowing reads.

Accept-set changes: the generic data door, sys_metadata / sys_metadata_history only

This applies to GET /api/v1/data/:object, POST /api/v1/data/:object/query and in-process findData.

  1. A cross-field comparand naming the body, checksum, previous_checksum or change_note changes from 200 to 400 INVALID_FIELD, with param: filter and field set to the column, before the engine is asked. This covers where, $not and an aggregation filter.
  2. A family-column predicate, key or comparand, nested more than 32 combinators deep changes from 200 to 400 INVALID_FIELD, in the same envelope.
  3. Some shapes were refused before and stay refused, with a different refusal, in-process only. An unrecognised $ key wrapping a family column, an array-form aggregation filter naming one, and a malformed $field reference to one were the engine's INVALID_FILTER. They now get the family's INVALID_FIELD. Over HTTP, the array-form aggregation filter is still refused earlier by REST validation.

Unchanged:

  • Every other object. The collector answers [] outside the family, so the door collects nothing there.
  • Dotted keys. The dotted-path verdict still answers first.
  • Every search answer (explicit list, default narrowing, emptied set). These are pinned identical at the door and the seam.

@objectstack/runtime (patch)

The seam (stored-metadata-reader-seam.ts) changes as follows:

  • narrowFamilySearch is deleted, along with the filterHeadFields wrapper and the @objectstack/plugin-security collectConditionFields import. The seam now calls the door's two exports.
  • count runs the query the guard returns (H2).

The seam's accept set is unchanged: everything it refused before it still refuses. Two seam refusals change code, from the engine's INVALID_FILTER to the family's INVALID_FIELD: an unrecognised $ key wrapping a family column, and a malformed $field reference to one.

Readings on the dispatch's hypotheses

  • H1 (dotted / cross-field), confirmed in part. The dotted half is moot at the door: the dotted-path verdict refuses it. The cross-field half is a measured gap. The depth backstop was a second gap the measurement found.
  • H2 (count discards the guard's return), confirmed and corrected. count now consumes the guard's return. The pin: a default search through count arrives narrowed.
  • H3 (having), moot. See the table. The collector deliberately does not read having: an aggregation alias spelled like a family column is a legitimate count, and a parity control pins it as run.
  • H4 (the door's own answers unchanged), confirmed.
    • The existing door suites pass unchanged: #21207 search, hash and note, #21120 body.
    • The parity table pins explicit list, default search and emptied set identical at the door and the seam.
  • H5 (other collectConditionFields readers). After this PR its one reader is @objectstack/plugin-security's own FLS predicate guard (collectQueryFields / assertReadableQueryFields). That guard does not judge the family. @objectstack/runtime still depends on @objectstack/plugin-security through security/resolve-execution-context.ts. No plugin-security edit.

Tests

  • New in packages/metadata-protocol/src/protocol.data-door-stored-metadata-filter-reads.test.ts:
    • collector cases (16 shapes on both tables, plus non-family, cycle and shared-node pins);
    • narrowing cases;
    • door pins: 6 gap shapes × 5 family columns, each refused with code / status / param / field / object and the engine never asked;
    • scalar-column and non-family controls.
  • New in packages/runtime/src/stored-metadata-reader-seam.test.ts: the door / seam parity table. It is one table of 24 cases: 7 search cases (explicit list ×4, default ×2, emptied); 14 collector cases (direct, dotted key ×2, cross-field comparand ×3, dotted reference, list reference, $not, unknown $ key, depth 33 ×2, aggregation filter ×2); and 3 controls. Each case runs through findData and through the seam, and the two outcomes must be equal. There is also a narrowed-default pin and the count pin.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run: 208 files passed, 3 skipped; 3266 tests passed, 19 skipped (at 5513190ca5, after merging main).
  • pnpm --filter @objectstack/runtime exec vitest run --project local: 318 files passed; 4514 passed, 19 skipped (at 5513190ca5). --project repo: 3 files, 751 passed (at 9be38c5987).
  • At the final head 0ac5749662:
    • the three family door suites: 99 passed;
    • the seam, reader-contexts, body-writes and boundary suites: 86 passed.
  • typecheck: both packages green (at 5513190ca5). --listFiles confirms both new tests are inside each package's tsc program.

Reverse verification (both at be99ceee6a, through scripts/ablation-replace.mjs, mutation and restore proven on disk)

  1. The seam reverted to its own narrowing and collector. The file was swapped to its base blob 27efc3412999; on disk, narrowFamilySearch = 1, collectConditionFields = 3 and the new collector = 0. Result: 2 red (the parity case for an unrecognised $ key wrapping a body filter, and the count pin) and 41 green. Restored with git checkout HEAD -- PATH: blob == HEAD 51fe56bb5e73, git diff HEAD empty.
  2. The door's collector reverted to the ingress key collector expression. On disk the ablation marker = 1 and the new call = 0. Result: exactly the 30 door-gap pins red (6 shapes × 5 columns); the 69 others stayed green (collector and narrowing units, controls, the existing #21207 / #21120 door suites). Restored: blob == HEAD fa64d2b26cd9, git diff HEAD empty.

Both are src-resolved: each subject is imported by relative path, so no dist/ leg was needed.

Gates (at 0ac5749662)

  • node scripts/pm/dispatch-gates.mjs --commands derived 64 families; all 64 were run and exited 0. Reconciled with --ran: 64 run, 0 NOT-MEASURED, 0 UNRUN.
  • Two needed a step first:
    • check:dual-build-cjs-loads printed PREREQUISITE NOT MET before a full turbo build, then exited 0.
    • check:engine-double-contract flagged the new door doubles' findOne. findData never reads findOne, so the doubles carry none, and the pinned ledger is untouched.
  • Lint is a proven narrowing. ESLint ran with --no-inline-config --format json on the 6 touched TS files at 0ac5749662: 6 files, 0 errors, 0 warnings. The checked population is read from eslint.config.mjs (--print-config per file). Type-aware linting is not enabled anywhere: no parserOptions.project or projectService, project=null per file. So this diff cannot move any untouched file's verdict.

File surface

  • Declared:
  • Beyond the claim's list, one sentence of comment in packages/metadata-protocol/src/metadata-redaction.ts. It is the storedMetadataBodyPredicateRefusal docblock's parenthetical, which named the old collector as the source of filterFields and would have been false after this change. No code.
  • The changeset carries Clause-②: yes (narrowing) and an ADR-0087 not-required (no-migration-prescription) disposition marker. check:adr-0087-registration reads it.

Acceptance notes (observations, not filed)

  • driver-memory does not resolve a cross-field reference in where. It compares against the literal reference object, so on a non-family object { "name": { "$eq": { "$field": "name" } } } answered 0 rows (SQL: every row). It was measured at b610eabf72 through the generic data door on a memory-backed composition. Public reach is not measured after 9a4182a752 (the in-memory engine is no longer a boot store), so it is not filed. Carrier: none.
  • count / count_distinct over a family column is still served, at the door and the seam alike. It discloses equality only, which the keyed content hash already serves per row.
  • The ingress gate's collectFilterFieldKeys keeps its 32-level backstop for the existence question. That question is not the family's. The backstop's reach on an unknown field nested below it is an unmeasured inference.

Generated by Claude Code

claude added 8 commits October 3, 2026 16:35
…narrowing and filter-field collector; the reader seam calls both (WIP)

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…ng at the door, and door/seam parity (WIP)

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…l, patch runtime)

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…or-narrowing-export

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…or-narrowing-export

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…indData never reads one)

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 23 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/metadata-protocol/src/metadata-redaction.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5c9138b4b672ade8cbc99d3e48e0479a9ebabf74.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/metadata-protocol/src/metadata-redaction.ts) — pages documenting those are invisible to this run
  • 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 — 31 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 5c9138b4b672ade8cbc99d3e48e0479a9ebabf74 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5c9138b4b672ade8cbc99d3e48e0479a9ebabf74

⚠️ 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 5c9138b4b672ade8cbc99d3e48e0479a9ebabf74 → 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: CONTRACT_REVIEW_TIER
Head-sha: 0ac5749662d7dddffd9dc011e0e71375d8d79451
Local-runs: none

Inputs read: card #21544 (its body and all 5 comments), PR #21619 (body, file list, net diff against main at the head) and the head's check-runs. For diff context only, the base and head blobs of the touched files and their direct dependencies were read through read-only git show; nothing was built, run or re-run. The dispatching seat's ACCEPT was read as a thread comment, not adopted. Rendered by an isolated at-tier reviewer and adopted by domain:engine#1.

① Derived judgments

Public surface, @objectstack/metadata-protocol (src/index.ts) — RIGHT.

  • The PR adds two value exports, collectStoredMetadataFilterFields(object, query) and narrowStoredMetadataSearch(object, query, schema, wireSpelling?), and one type export, StoredMetadataSearchSchema. All three are additive.
  • Both functions are the ones the door itself now calls (protocol.ts at head: the narrowing at the search gate, the collector at the family filter refusals). So the ruling's "one of each, owned by the door, called by door and seam" holds.
  • The narrowing is the old private method, moved with its answers intact:
    • its schema gate (fields absent, non-object, an array or empty → run as is) is resolveQueryFields' own rule, restated;
    • its definition comes from the same this.engine?.registry?.getObject?.() call;
    • by that predicate's body, the per-field test !storedMetadataSearchRefusal(object, [field], param) equals the old STORED_METADATA_UNSEARCHABLE_COLUMNS.includes(field).
  • Keeping collectFilterFieldKeys private and un-widened, instead of exporting it with stricter semantics, is RIGHT. It remains the sole collector behind assertFilterFieldsExist, byte-unchanged apart from a docblock. That collector answers the existence question for every object, in a fail-open direction; the ruling's single collector is the family's.

Door accept set, sys_metadata / sys_metadata_history only — a NARROWING, RIGHT. The door here is findData: GET /api/v1/data/:object, POST /api/v1/data/:object/query and in-process.

  • (a) A cross-field { $field } comparand. One naming metadata, checksum, previous_checksum or change_note, in where, under $not or in an aggregations[i].filter, moves from a run query to 400 INVALID_FIELD (param: filter, naming the column), before engine.find / engine.aggregate is called. The new collector now feeds the existing family refusals:
    • storedMetadataBodyPredicateRefusal;
    • storedMetadataHashEvaluateRefusal, whose hashColumnOf covers the note column through STORED_METADATA_HASH_BEARING_COLUMNS.
  • (b) Depth. A family-column key or comparand nested beyond the ingress collector's 32-level backstop moves the same way.
    • The new walk is iterative, with two WeakSets and no depth cap.
    • The old door collector pushed keys only, recursed $and / $or / $not only, ignored arrays and stopped at depth 32.
    • So the refused set is a strict superset of the old one.
  • (c) Three shapes change refusal code only, from the engine's INVALID_FILTER to the family's INVALID_FIELD:
    • an unrecognised $ key wrapping a family column;
    • an array-form aggregation filter naming one (lowered through isFilterAST / parseFilterAST);
    • a $field reference with a malformed addDays. FieldReferenceSchema is a plain object schema, so that is the only string-$field shape it refuses.
  • Ordering — RIGHT.
    • The collector runs after assertFilterFieldsExist, so the unknown-field and dotted-path verdicts answer first: metadata.x stays the dotted-path refusal under param: where.
    • It runs before the aggregate / find routing.
  • Pins: the 30 door pins (6 shapes × 5 columns) assert the code, status, param, field and object, and that no engine verb was called.

Non-family objects — NO ANSWER MOVED, RIGHT.

  • collectStoredMetadataFilterFields returns [] outside isStoredMetadataBodyObject.
  • Both refusal predicates return undefined outside the family, whatever they are fed.
  • narrowStoredMetadataSearch returns undefined outside the family, exactly as the private method did.
  • The ingress existence gate and its collector are unchanged.
  • Two controls pin it: scalar columns on a family table, read through a comparand and at depth 33; and file_blob reading its own checksum the same two ways.

Runtime reader seam (stored-metadata-reader-seam.ts) — ACCEPT SET NOT WIDENED, RIGHT.

  • The narrowing. The deleted narrowFamilySearch had the same control flow as the exported narrowing:
    • an explicit list → refuse or run;
    • a default search → resolveSearchFieldResolution minus the predicate's refusals;
    • an emptied set → refuse.
    • Its one extra schema gate (an array or empty fields) resolves to the same outcome: run as is.
  • The collector. The deleted one (@objectstack/plugin-security's collectConditionFields) collected key heads and schema-valid $field heads at any depth.
    • On every shape I could construct, the new collector collects at least those.
    • It is stricter in exactly two places, both shapes the engine refused anyway: an unrecognised $ key (it reads beneath it as a condition, where the old one added the $ key itself as a name), and a $field with a bad addDays. These are the two seam code changes the PR declares.
    • Nested-relation keys stay uncollected in both. That is parity, not a widening.
  • Runtime's public surface is unchanged: narrowFamilySearch and filterHeadFields were module-private, never exported from @objectstack/runtime. The @objectstack/plugin-security dependency stays declared and used (security/resolve-execution-context.ts).

Seam count — RIGHT. value.call(target, refuseOrNarrowStoredMetadataEvaluate(objectName, query, engine), ...rest) sits inside the async arrow.

  • What runs is the guard's return: the caller's query, or a shallow copy carrying narrowed searchFields for a default search.
  • A refusal still leaves as a rejected promise.
  • A non-family or non-record query passes by reference, as before.
  • The count pin asserts that the narrowed searchFields reach the repository.

Pins the ruling owes — PRESENT.

  • The door / seam parity table: 7 search, 14 collector and 3 control cases, each run through findData and through serveStoredMetadataReadsThrough, with equal outcomes required. It answers both "explicit list, default search and emptied set identically" and "the collector cases at both the door and the seam".
  • 16 collector unit cases run on both tables. The narrowing units cover both wire spellings and the emptied set.
  • The report's reverse verification is consistent with this reading. It was not re-run here.
    • The seam at its base blob turns exactly the $nor parity case and the count pin red.
    • The door collector reverted turns exactly the 30 gap pins red.

Check-runs on the head — not mine to wait on; the landing waits for them.

  • 36 runs: 29 completed (23 success, 6 skipped), 0 failed and 7 in progress at read time.
  • The 7 in progress: Lint & Repo Gates, Type Check · workspace, Test Core 1/6, 2/6, 3/6 and 5/6, and a second Check Changeset run (suite 100611201875). The first Check Changeset run succeeded.

② Semver level

  • @objectstack/metadata-protocol: minor, with a BREAKING paragraph — RIGHT. The diff adds two exports and narrows an accept set. Clause-②: yes takes at least minor, (narrowing) is BREAKING, and major is refused in the launch window (check-changeset-no-major).
  • @objectstack/runtime: patch — RIGHT. No export moves and no accept set widens. The changes are two refusal codes on already-refused shapes, and the count fix.
  • Clause-②: yes (narrowing), on the PR body and in the changeset — RIGHT and consistent. The claim's bare yes would have under-declared a measured narrowing.
  • ADR-0087 disposition not-required (no-migration-prescription) — RIGHT on the facts the gate checks:
    • both packages publish (neither manifest is private: true);
    • no registry id names a refused query shape;
    • nothing authorable is removed or renamed, so the migration FROM → TO rule owes nothing;
    • the changeset body carries a route sentence, not a prescription.
  • The changeset's refused shapes, its unchanged list and its route match ①. The completed Check Changeset run on this head is green.

③ Boundary flags

  • open_questions[0] (which rule governs a runtime test edit): the seat answered A, and I concur. The ruling's pins are "at both the door and the seam". The seam is reachable only from packages/runtime, and the edit is confined to stored-metadata-reader-seam.test.ts. Runtime source is touched on the one declared path only.
  • Deviation: Clause-②: yes (narrowing) against the claim's bare yes. Accepted; see ②.
  • Deviation: a new name, collectStoredMetadataFilterFields, instead of exporting collectFilterFieldKeys. Accepted; see ①. Exporting the existence collector with the stricter rules would have moved non-family answers, which the ruling forbids.
  • Deviation: one docblock sentence in metadata-redaction.ts, beyond the claim's list. Accepted: no code, and the old sentence named a collector that no longer feeds the refusal.
  • Deviations with no contract effect:
    • two origin/main merges (the net diff is the 7 declared files);
    • the engine-double-contract flag, resolved by carrying no findOne on the new doubles;
    • model-free commit trailers, per AGENTS.md;
    • the worktree cleanup.
  • The claim's ⛔ clauses held.
    • There is no @objectstack/plugin-security edit.
    • No protocol.ts hunk is in the hydration region. The hunks sit at the import block, at module scope beside collectFilterFieldKeys, at the removed private method, and at the two findData call sites.
  • The domain:cli pointer's three readings:
    • (1) count discarding the guard's return: fixed.
    • (2) having: measured moot. Its names are judged against the aggregated row, and the collector deliberately skips it. A control pins an alias spelled like the body column as a legitimate count.
    • (3) Dotted keys: measured moot at the door, where the dotted-path verdict answers first. The collector heads them anyway.
  • out_of_scope_findings: all noted with carrier none, and none blocking. Not filed by this read-only reviewer.
    • driver-memory compares a $field reference as a literal. Its reach is unmeasured since the in-memory engine left the boot store.
    • count / count_distinct over a family column discloses equality only, which the keyed hash already serves per row.
    • The ingress gate's own 32-level backstop on non-family objects is outside the family's question, and is unmeasured inference.
  • Reviewer residual, no verdict effect:
    • A family column spelled as a key inside a nested-relation condition ({ organization_id: { metadata: ... } }) is uncollected by the old and the new collector alike: by the grammar, it is another object's column.
    • Nobody has measured it at the door.
    • It is parity, not a widening; it earns one line on the card if the lane ever measures nested-relation filters on the family tables.

Implemented-by: claude/issue-21544-door-narrowing-export
Reviewed-by: session_017ErfyP2Rx7XWHJA27QjyUi

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 18:29
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 18:29
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 5d0e4e2 Oct 3, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21544-door-narrowing-export branch October 3, 2026 18:54
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

2 participants