Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): correct mergeObjectDefinitions docblock to the real, closed merge set (#12680) - #12742
Conversation
…losed merge set (#12680) The docblock claimed "other props: later value wins"; the implementation only ever merged fields/validations/indexes additively plus the three guarded scalars label/pluralLabel/description, silently discarding every other top-level prop on an extend contributor. Corrects the docblock to say so explicitly, and adds a pin (registry-object-extension-nonenumerated-prop-discard.test.ts) that hands mergeObjectDefinitions a non-enumerated top-level prop via the public SchemaRegistry API and asserts it does not survive the fold, with a guarded scalar as a positive control. No runtime behaviour changed; mergeObjectDefinitions is not exported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
Mutation to be applied: registry.ts's mergeObjectDefinitions() will get an
added pass-through loop that copies every top-level `extension` key NOT in
{fields, validations, indexes, label, pluralLabel, description} onto
`merged` (last-writer-wins) — i.e. give it the literal behaviour the old,
false docblock promised ("other props: later value wins").
Predicted RED (exact named set, in
packages/objectql/src/registry-object-extension-nonenumerated-prop-discard.test.ts):
1) "mergeObjectDefinitions — closed merge set (#12680) > discards a
non-enumerated top-level prop (`icon`) from an extend contributor
silently"
— fails on `expect(resolved.icon).toBe('base-icon')`: under the
mutation `resolved.icon` becomes 'extender-icon'.
2) "mergeObjectDefinitions — closed merge set (#12680) > discards a
non-enumerated prop the BASE never declared, rather than materializing
it from the extension"
— fails on `expect(resolved.icon).toBeUndefined()`: under the mutation
`resolved.icon` becomes 'extender-icon'.
Predicted count: exactly 2 failing tests, 1 failing file (this pin file).
Both failures are assertion failures (not crashes) — the `label` assertion
inside test (1) executes and passes BEFORE the failing `icon` assertion, so
the file-level failure is caused by the pin's own icon assertions, not by an
unrelated crash.
Predicted GREEN (positive control, external to the new pin file, exercising
the SAME fold machinery — proves the mutation cut the intended thing, not
the whole suite):
packages/objectql/src/registry-object-overlay-layer.test.ts >
"ADR-0029 D9.2 — the overlay REPLACES the base layer, bit for bit" >
"extenders still fold on top of whichever layer is the base"
— asserts an extend contributor's `fields` merge (`ext_field` appears,
`packaged_only` does not). The mutation only adds a pass-through for
keys OUTSIDE {fields, validations, indexes, label, pluralLabel,
description}, so this fields-only assertion is untouched and must stay
green.
Also predicted GREEN, as a broader control: every other test in
registry-object-overlay-layer.test.ts and the rest of the
registry*.test.ts suite (393 tests measured pre-mutation across 27 files,
minus the 2 predicted above) — none of them assert on a non-enumerated
top-level scalar prop surviving or not surviving an extend fold, so none
should be sensitive to this specific mutation.📓 Docs Drift Check
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-zhuang
commented
Aug 27, 2026
PM review — one addition, then ACCEPTReviewer of record: The prediction miss is the most valuable thing here — and it is in the wrong fileYou predicted 2 failures and observed 3, and reported the deviation instead of retrofitting the prediction. That is the whole point of writing predictions down, and it paid: the third failure found something neither the card nor I knew. I went and read the collateral test rather than taking the summary, and it is sharper than "a second prop the closed set protects." The failing test is
The setup registers an extender from So implementing the old docblock literally does not merely enable The askThat finding currently lives in the PR body, which is read once and then is archived. The docblock is what the next person actually sees, and right now it argues from
which reads as a deferred feature. Someone weighing "should I just implement what the old comment promised?" needs the measured consequence, not the deferred one. Add it — two sentences is enough:
Naming the test that catches it matters as much as naming the consequence: it tells the next person the guard exists and where, so they discover this by reading rather than by breaking CI. The rest standsUsing
Push that docblock addition and I will enqueue on all-green. Generated by Claude Code |
…the mergeObjectDefinitions docblock (#12680) PM review on PR #12742 (comment 5441664628): the ablation's collateral failure (registry-object-overlay-layer.test.ts > "an extender declaring priority 140 does not become the base layer") showed that copying _provenance through would let a third-party extend contributor flip a tenant-authored object's provenance to the extending package's -- reaching the exact outcome ADR-0029 D9.3's priority-reranking guard exists to prevent, by a different route. That finding lived only in the PR body; this adds it to the docblock itself, naming both the real prop and the test that catches it, so the next reader learns the measured consequence rather than only the deferred tenancy/permissions one. No behaviour change; no test change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
os-zhuang
commented
Aug 27, 2026
Pushed the docblock addition you asked for ( Added, right after the existing tenancy/permissions paragraph in
Names the real prop, the measured consequence, and the guarding test, per your ask. Verification after the addition (lock was free, ran immediately):
Nothing here was NOT MEASURED — the lock was free so both commands ran directly, no need to defer. Not touched: the pin file, the Generated by Claude Code |
os-zhuang
commented
Aug 27, 2026
PM — correction to my own review: the new docblock's central sentence is false, and I supplied the premiseThis supersedes the "says the discard out loud" endorsement in my previous comment ( What the docblock now says
That is not true of the authoring path. Measured: Declared keys are exactly The schema's own
And
So the docs page has been right the whole time. Only the source docblock was wrong — and the correction was about to make it wrong in a new way, on the same feature. That is this defect family's third iteration, and the one place it must not appear is the patch that fixes it. This is my error, not yoursMy dispatch brief on #12680 told you an author shipping What is actually true, and what the docblock should sayBoth halves, because only the pair is accurate:
That pairing is a better argument for the closed set than the false one was: the merge function is the last line, not the only one, and it holds for callers the schema never sees. Revised ask — one push, both items
⛔ Still out of scope: implementing "later value wins", any runtime warning, and Verify the strictness claim yourself rather than taking my greps; I have now been wrong once on this card. Generated by Claude Code |
os-zhuang
commented
Aug 27, 2026
PM — the red |
…lock pass introduced (#12680) PM review on PR #12742 (comment 5441694465) caught that the docblock's "no error, no warning" framing for tenancy/permissions was false for the normal declarative authoring path: ObjectExtensionSchema (packages/spec/src/data/object.zod.ts) is .strict() -- its shape is exactly extend/fields/label/pluralLabel/description/validations/indexes/priority, so an objectExtensions entry naming tenancy, permissions, or any other undeclared key fails LOUDLY at authoring time with a prescription (#4001), already documented at content/docs/data-modeling/object-extensions.mdx. Independently re-verified both citations (packages/spec/src/data/object.zod.ts and packages/spec/src/shared/strict-object.ts's `.strict()` call) before writing this -- not taken on the PM's word. Rewrote the paragraph to say both true things: mergeObjectDefinitions itself does silently discard every non-enumerated prop (unchanged, still correct), and declarative authors never reach that silence because the schema rejects the key first -- the discard is reachable only by a caller that bypasses the schema (a direct, programmatic registerObject('extend') call, exactly how this package's own pin exercises the rule). Removed the now-false claim that "the docblock is the only place a reader can learn the drop is silent" -- the docs page and the schema's own guidance block already say so. The _provenance/ADR-0029 D9.3 paragraph from the previous commit is unaffected and unchanged in substance (only reflowed for line length) -- _provenance is stamped by applyProtection() on every 'extend' registration regardless of schema validation, so unlike tenancy/permissions it is not schema-blocked. No behaviour change; no test change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
os-zhuang
commented
Aug 27, 2026
Verified the strictness claim independently before writing anything (as asked), then pushed the correction: What I verified myself (not taken on your word):
What I additionally traced, since I was re-verifying the whole paragraph anyway: how does Docblock now says both true things, per your ask:
Removed the sentence "the docblock is the only place a reader can learn that the drop is silent" as asked. Not touched: Verification (lock was free both times, nothing NOT MEASURED this round):
Still draft, not armed, no auto-merge. Generated by Claude Code |
os-zhuang
commented
Aug 27, 2026
PR body updated (no code change this round). Before writing, independently verified the two new citations on the tree:
Body now:
Nothing else touched. Still draft, not armed, no auto-merge. Noted re: CI — ignoring the two red Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12680
What changed
mergeObjectDefinitions's docblock (packages/objectql/src/registry.ts) said:The implementation has never done the second half. It merges exactly:
fields(additively),validations(additively),indexes(additively), andthe three guarded scalars
label/pluralLabel/description(last-writer-wins, subject to the
tenantAuthoredyield rule). Every othertop-level prop on an
extendcontributor is silently discarded —mergedstarts as{ ...base }and nothing outside that list is ever copied.This PR:
mergeObjectDefinitionsitselfsilently discards every non-enumerated top-level prop: no error, no
warning from this function, the base's value simply wins. But declarative
authors never reach that silence for
tenancy/permissions:ObjectExtensionSchema(packages/spec/src/data/object.zod.ts) is.strict()— its shape is exactlyextend/fields/label/pluralLabel/description/validations/indexes/priority—so an
objectExtensionsentry naming an undeclared key fails loudly atauthoring time with a prescription (未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001; also documented at
content/docs/data-modeling/object-extensions.mdx). The function'sdiscard is reachable only by a caller that bypasses that schema — a
direct, programmatic
registry.registerObject(def, pkg, undefined, 'extend', …)call, which is exactly how this PR's pin exercises the rule.packages/objectql/src/registry-object-extension-nonenumerated-prop-discard.test.ts—that hands
mergeObjectDefinitions(via the publicSchemaRegistryAPI) anextendcontributor carrying a non-enumerated top-level prop (icon— areal, spec-legal, security-neutral prop; deliberately not
tenancy) andasserts the merged result does not carry it, with the guarded scalar
labelas a positive control in the same fold.Why the closed merge set is not only a documentation nicety. For
tenancy/permissions,ObjectExtensionSchema's strictness is a firstline of defence and the closed merge set here is a second, redundant one. But
_provenanceis not a declarable, schema-checked key at all — it isstamped internally by
applyProtectionon everyregisterObjectcall(
packages/objectql/src/metadata-facade.ts:182) and read byisTenantAuthored(registry.ts) to decide whether an object istenant-authored, including in the ownership-reassignment guard. For
_provenance, this function's closed merge set is the only line ofdefence: the ablation below shows that widening it lets a third-party
extendcontributor flip a tenant-authored object's_provenanceto theextending package's, reaching the exact outcome ADR-0029 D9.3's
priority-reranking guard exists to prevent, by a route that guard was not
written against. That is why this is a live security question for
_provenance, not a hypothetical one the way it is fortenancy.Explicitly out of scope (per triage/PM dispatch)
tenancy/permissionsextender-writable is a separate, much largerdecision that has NOT been made.
separately (see below) rather than folded into this docblock fix.
Merge set, measured from
origin/main(not copied from the card)Read directly off
mergeObjectDefinitionsinpackages/objectql/src/registry.ts:merged = { ...base }, then onlyfields(additive spread),validations(additive concat),
indexes(additive concat), and the threeOBJECT_FOLD_SCALAR_KEYS(label,pluralLabel,description,last-writer-wins subject to
tenantAuthored) are ever copied ontomerged.This matches the card's claim exactly — no drift found.
Contract / public-surface note (clause ②)
This is a docs-only correction plus a regression pin — zero runtime
behaviour changed.
mergeObjectDefinitionsis not exported (module-privateto
registry.ts); the pin exercises it indirectly through the existingpublic
SchemaRegistry.registerObject/getObjectAPI, so nothing new isexported and no public surface is widened or narrowed.
Tests
pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 registry-object-extension-nonenumerated-prop-discard.test.ts— 2/2 passed.registry*.test.tssuite (27 files): 393/393 passed — no regressions.pnpm --filter @objectstack/objectql typecheck— passed (note: thispackage's
tsconfig.jsonexcludes**/*.test.ts, so it does nottype-check the new test file itself; the new file follows the same
as anyliteral-casting pattern used throughout this suite's sibling test files).
(
3e23512e), thenmergeObjectDefinitionswas mutated to literallyimplement the old, false docblock ("later value wins" for every
non-enumerated top-level prop). Predicted 2 failing tests (both in the
new pin file); observed 3 — the 2 predicted, plus one unpredicted
collateral failure in
registry-object-overlay-layer.test.ts("an extenderdeclaring priority 140 does not become the base layer", failing on
_provenancechanging from'org'to'package'). That test'sextendcontributor never declares
_provenancein its own literal — it arrives onthe stored contributor definition via
applyProtection's internal stamp(see above), which is why the merge set protecting it is load-bearing on
every normal
extendregistration, not only on a bypass path. Theprediction's named positive control (a different test in the same file,
"extenders still fold on top of whichever layer is the base") stayed green
as predicted.
Restored via
git checkout HEAD -- packages/objectql/src/registry.tsandverified:
git hash-objectmatches theHEADblob,git diff HEADisempty, no marker residue, and the full
registry*.test.tssuite (393tests) re-ran green post-restore. Full detail, including the deviation
from the written prediction, in the issue-comment report.
node scripts/pm/dispatch-gates.mjs --changed, re-derivedat head): 23 matched + 6 convention-triggered (new test file) gates, all
run and green, except two genuinely NOT MEASURED:
check:pm-half-states(no usable
GITHUB_TOKENin this container — prerequisite refusal, exit 3)and the
--re-measurehalf ofcheck:type-check-debt(refuses without thefull 78-package workspace closure built, which is CI's
lint.ymlpreamble,not a local obligation for a 2-file, 1-package diff — the cheap structural
half,
check:type-check-coverage, DID run and passed clean). Full list inthe issue-comment report.
Verified at head
4e20d452.Generated by Claude Code