Repository navigation
feat(spec): export the controlled_by_parent exactly-one-master predicate; lint reads it - #23496
Conversation
…ads it Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
…a; changeset Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
…ster predicate Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
… f — the spec predicate reads it Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
…port-cbp-master-predicate
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a5bb85c821e2adf117d36d51fd6330761a22739a && git checkout a5bb85c821e2adf117d36d51fd6330761a22739a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 873151021f640a7a2b8675f2c38ff10ec8a7230b 7b2581304820cd8e9fc1b9ad336b67ba6b42ada5 && git checkout -B drift-repro 873151021f640a7a2b8675f2c38ff10ec8a7230b && git merge --no-ff 7b2581304820cd8e9fc1b9ad336b67ba6b42ada5
node scripts/docs-audit/affected-docs.mjs --json 873151021f640a7a2b8675f2c38ff10ec8a7230b
|
…ternal — it is re-exported from @objectstack/spec/data Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #23272 (body, triage ① Derived judgmentsDelta Public surface widened —
Landing spot vs. triage direction ( Accept-set changes in
#5017 receiver meta-test ( Mirror/baseline obligations: ② Semver level
③ Boundary flagsDev
Dev
Reviewer findings:
Check-runs on Implemented-by: VERDICT: PASS Adopted by Generated by Claude Code |
Fixes #23272
Clause-②: yes
What
@objectstack/spec/datanow exports thecontrolled_by_parentexactly-one-master predicate (ADR-0055 §1), from the module that owns it (packages/spec/src/data/controlled-by-parent-master.ts).ObjectSchema's refusal and@objectstack/lint'scbpMasterCandidatesread it, so a producer, the parse and the linter cannot drift.controlledByParentMasterRefusal(objectName, sharingModel, fields)(already the parse's judge, now public with its typesControlledByParentMasterRefusalandControlledByParentMasterShape) answers the object-level question "does this object satisfy the exactly-one-master rule?".undefinedmeans yes. A refusal carries the parse issue'spathandmessage, the object'sshape, and itsmaster_detailand requiredlookupfields.isControlledByParentMasterField(field)(new) answers the field-level half, "does this field count as the master?": amaster_detaildeclaredrequired: true, and nothing else. The refusal now counts with it.Why these two, and not one
The card's direction asks for one predicate that both
ObjectSchemaand the lint read. Measured onmain, they need different granularity:packages/service-ai-studio/src/tools/sharing-model.ts,defaultSharingModel) needs the object-level verdict for a field map it is about to write:controlledByParentMasterRefusal(name, 'controlled_by_parent', fields) === undefined. The refusal function already returns exactly that, along with the shape and message, so no narrower boolean wrapper is added.cbpMasterCandidateslists each candidate with itsreferencefor the ambiguous-relation message. It reads the array field form as well as the map. It also does not count amaster_detailthat names noreference(pinned byerrors when the required master_detail names no reference targetandpicks up a later relation when an earlier one names no target). So the lint reads the field-level predicate, which is the part the refusal counts, and keeps itsrefOffilter and its ownlengthchecks for its two rule ids.What counts as a master is now spelled once, in the spec.
Landing spot
This is the claim's file surface. The owning module is
controlled-by-parent-master.ts, notobject.zod.tsas the card says.object.zod.tsalready called it, soobject.zod.tsis unchanged. The re-export is inpackages/spec/src/data/index.ts. Dependency direction:@objectstack/lintalready imports@objectstack/spec/data(referenceCarrierOf), so no new edge and no cycle.Pins
packages/spec/src/data/controlled-by-parent-master.test.ts(new) imports through thedataentry. For each shape v18 upgrade: stored controlled_by_parent objects without exactly one master_detail (two masters, none, a required lookup, required:false) are denied after upgrade with no conversion or fate #23270 lists (several-required-masters,several-masters,master-not-requiredwithrequired: falseand withrequiredomitted,required-lookup-only,no-master), plus the two satisfying shapes, the export andObjectSchema.safeParseagree on the verdict, the issue path and the message. A satisfying shape parses whole. Any other sharing model getsundefinedfrom the export and is accepted by the parse. A truth table covers the field predicate, and a test shows that the refusal's verdict is "exactly one field counts".packages/lint/src/validate-security-posture.test.ts: the validate-expressions / validate-security-posture 也有同形的 spec 不声明键的??别名读法(#5009 建议 3 的核对结果) #5017 receiver meta-test row forfdropsrequired. The lint no longer readsf.requireditself, because the spec predicate reads it. All lint findings, paths and texts are unchanged: every existing cbp test passes without edits.Ablation (run once, not kept)
The fix was committed first.
scripts/ablation-replace.mjsthen madeisControlledByParentMasterFieldalso count a requiredlookup(anchor 1 → 0, blob1500e7c092d0→f3afdbb79519), and the spec was rebuilt.ablation-dist-preflightfound the marker in 24 built files, which proves the lint resolves throughdist/.validate-security-posture.test.ts: 6 failed (e.g.errors on a REQUIRED lookup (the retired third tier),the parse and the lint agree on a required lookup). So the lint reads the spec predicate.controlled-by-parent-master.test.ts: 3 failed (a required lookup only,one required master_detail beside a lookup, and the field truth-table row).1500e7c092d0),git diff HEADis empty, the spec was rebuilt, and--absentfound the marker in none of 232 built files. The tree is clean.Tests and gates
All readings below are at head
d88df525c, after the merge oforigin/main. Every heavy run went throughos-verify-lock.sh.pnpm --filter "@objectstack/lint..." build: exit 0. This also rebuilt@objectstack/spec.pnpm --filter @objectstack/spec check:generated: "All 14 generated artifacts are up to date".api-surface/data.jsonandexport-origins/data.jsonwere regenerated in this PR: three functions and types added to thedataentry, nothing removed.pnpm --filter @objectstack/spec test: 677 files, 20599 passed | 1 todo.pnpm --filter @objectstack/spec test:repo: 60 files, 1029 passed. This was run at the pre-merge head7c40b9b6b. The merge brought nothing into the files this PR touches.pnpm --filter @objectstack/lint test: 139 files, 6726 passed.pnpm --filter @objectstack/spec typecheckandpnpm --filter @objectstack/lint typecheck: exit 0. These ran before the merge, and the merge did not touchspec/src/dataorlint/src.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 88 derived families, all exit 0. The--ranreconcile says "88 run, 0 NOT-MEASURED (a DERIVED zero — all 88 recorded an exit code and none of them is 3)".check:nul-bytes: exit 0.packages/spec. The refusal function body only swaps an inline filter for the extracted predicate, as the 20599 spec tests and the new pin show. So no consumer package owes a re-run beyond@objectstack/lint, which is the one importer of the new export.Acceptance notes
resolveCbpRelation(packages/plugins/plugin-security/src/security-plugin.ts) still keeps its own reading. It reads the unparsed registry with the reference aliases, and this card's direction names only the parse and the lint. Not changed here. carrier: none.controlledByParentMasterRefusalanswersundefinedwhenfieldsis not an object map, as it did before. A producer must pass the field map it writes. The JSDoc and the changeset say so.defaultSharingModel(at cloudmaintoday) counts anymaster_detailwithrequired !== false, and so treats an omittedrequiredas a master.ObjectSchematreats that asmaster-not-requiredon the authoring path. Adopting the export makes cloud's answer the parse's. That adoption is cloud's change, not this PR's.Generated by Claude Code