Repository navigation
fix(spec,lint)!: an objectExtensions entry that leaves a controlled_by_parent object without exactly one required master_detail is refused by defineStack and the posture lint - #23454
Conversation
…its merged objectExtensions field set Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
…in the #5017 meta-tests Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
…tension-master-detail
📓 Docs Drift CheckThis PR changes 2 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 94ae00844bbeb3adf51e6d127b26cba7481f7460 && git checkout 94ae00844bbeb3adf51e6d127b26cba7481f7460
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 05ab9ef895261d5c5f06ec2a5714eb71bd6b61d9 4daa50d71fad5e0cbe4fce2ec2f0b1dd4e436cd7 && git checkout -B drift-repro 05ab9ef895261d5c5f06ec2a5714eb71bd6b61d9 && git merge --no-ff 4daa50d71fad5e0cbe4fce2ec2f0b1dd4e436cd7
node scripts/docs-audit/affected-docs.mjs --json 05ab9ef895261d5c5f06ec2a5714eb71bd6b61d9
|
席位核对ACCEPT · It queues after one
1. Shape (read on GitHub, not from the report):
2. Premises, measured by the dev (the registry probe, not committed):
3. Gates:
4. Deviations, accepted:
5. Out of scope: two Acceptance notes,
6. Landing:
Generated by Claude Code |
Contract reviewServed-tier: Inputs: card #23210 (body; triage ① Derived judgments(a) The narrowing — right.
(b) Visibility — right, with one blind spot wider than it needs to be (stated, not a FAIL).
(c) The lint door — parity holds; the rule is read once, the fold is written twice, and the drift guard is the parity table. (d) The prebuilt-artifact consequence — right, disclosed, and the prescription reaches its reader by the same channels its sibling uses. Before: (e) Public surface — none added. The diff's only new ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #23210
Clause-②: no (narrowing)
What changes
ADR-0055 §1 says a
controlled_by_parentobject derives its master from exactly onemaster_detailfield declaredrequired: true.ObjectSchemaand the security-posture lint refuse an object that declares anything else. Neither judged the object the runtime runs: at boot the registry folds everyobjectExtensionsentry onto its target, so an extension could add a second required master, or demote the one. The stack passeddefineStack,os validateand the lint, and the runtime then derived no master and failed closed.Both authoring doors now judge the merged field set when the stack declares the target:
@objectstack/spec: a new refinement,applyControlledByParentExtensionGate, chained onObjectStackDefinitionSchemaafterapplyApiEndpointGatesandapplyPermissionSetOwnerBindingGate. It calls the existingcontrolledByParentMasterRefusal(unchanged, still internal) on the merged fields, so the message is the declared object's own.defineStackthrowsSTACK_SCHEMA_INVALID(422) carrying the issue.@objectstack/lint:validateSecurityPosturegains a merged-shape pass that reportssecurity-controlled-by-parent-ambiguous-relationor-no-relationat the extension. The two finding sites are now shared push helpers, so the merged and the declared finding are written once.cbp-extension-merged-master-refused, withrelevantWhen: objectExtensions, and a BREAKING changeset with the FROM → TO table and the remedy.No export, signature or error code is added or removed (
check:api-surface: unchanged). Not touched:packages/objectql/src/engine.ts(the runtime merge and its fail-closed refusal stay the backstop) andObjectExtensionSchema's own accept set.The visibility reading implemented (H2 / H3)
nameequality, nothing looser. The registry keys an extension by itsextendstring verbatim (computeFQNis the identity), andgetObjectresolves a short name to the first key registered under it. Measured on the registry:extend: 'cbpx__cbpx_line'opens its own ownerless entry and never merges intocbpx_line. So a prefixed spelling is unjudged, and pinned so.packages[](H3): the artifact is the visibility boundary. With nopackages[], the stack'sobjectsare extended by itsobjectExtensions. Withpackages[], an extension in any body judges an object any body declares, or a top-level object no body declares (the residual the engine registers too). Top-levelobjectExtensionsare not read there, because the engine registers a multi-package stack's bodies and the residual's objects, never its top-level extensions (ObjectQLPlugin'smanifest.register,unclaimedTopLevelObjects). Why not one-body-only:composeStacks(…, { manifest: 'preserve' })emits every definition insidepackages[]only, so a per-body reading would leave the cross-package case, which is whatobjectExtensionsis for, unjudged atos validateandos build. The lint's precedent is the same reach (validate-translation-references.tsandvalidate-mapping-target-fields.tsfoldpackages[].manifest.objectExtensions).prioritythat decides it. In that last case the registry's winner is registration order (dependency-topological,resolveArtifactPackageOrderin@objectstack/core), which the spec cannot compute. Measured: at p200 vs p200 the later-registered package won.The merge, as measured on the registry (H1)
A throwaway probe against
SchemaRegistryon this branch (scratch, not committed):cbpx_lineothermaster_detail=[header:true, other:true]headerwithrequired: falsemaster_detail=[header:false]: the whole field is replaced by namesharingModel: 'private'sharingModel=controlled_by_parent: not carriedother: text) p300The judge computes exactly that: base fields, then extensions in ascending
priority(equal priority in package order), one per package per target (last entry wins), each field replaced whole.Where the issue sits (H5)
At the
fieldsof each extension that wrote a key which is amaster_detailin the declaration or in the merged set:objectExtensions.0.fields, orpackages.0.manifest.objectExtensions.0.fields. That is the line that added the second master or demoted the one. The single-object control is unchanged:objects.N.fieldsfor two masters,objects.N.sharingModelfor none. The merged issue's message is byte-equal to the control's (pinned).The lint door (H4)
os validateparses withObjectStackDefinitionSchema.safeParse(validate.ts, step 2b) before the rule table runs, so with this change the card's stack stops at the parse and the lint never sees it there. The rule table's input is the parsed union stack, which keepsobjectExtensions(a declared key). The lint pin still matters for the unparsed callers:os linton a raw config, the lint scorer, and direct calls. The lint test file holds the two doors to one verdict per shape (8 shapes, parse paths equal to lint paths).Tests
At
4daa50d71(this branch withmaina7a135dc2merged, after the seat's overlap notices for #23411 and #23412), unless noted.mainhas since moved to05ab9ef89, and none of those commits touches a file in this diff:@objectstack/spec, unit layer (vitest run --project local): 675 files, 20487 passed, 1 todo.@objectstack/lint, full: 139 files, 6718 passed.@objectstack/objectql, unit layer (--project local): 415 files, 8087 passed.@objectstack/cli,--project unit: 285 files, 4278 passed, 29 skipped. Two more files (published-subpath-console.pin,published-subpath-hook-body.pin) refuse to run withoutpackages/cli/dist, socliwas built and they were re-run: 2 files, 29 passed. Theintegrationlayer is declared to CI (no spawn entry or integration file touched).@objectstack/specgreen atf9744329d(the merge moved no spec source);@objectstack/lintgreen at4daa50d71.New pins:
packages/spec/src/stack-cbp-extension-master.test.ts(12) andpackages/lint/src/validate-security-posture-extension-merge.test.ts(16). Each refusal asserts the issue'scodeand path (or the finding's rule id and path) and compares the message with the declared control. Each silence sits beside a twin that differs in one variable and is refused. The #5017 source-scan meta-tests invalidate-security-posture.test.tstable the new reads (stack.objectExtensions,stack.packages,entry.manifest,body.objects/body.objectExtensions,ext.extend/ext.fields/ext.priority), each checked against the live schema shape.Ablations (committed state, through
scripts/ablation-replace.mjs, which verifies the mutation and its restore on disk):.superRefine(applyControlledByParentExtensionGate)(anchor 1 → 0, blobecc31d4e3de3→b0e64ef6c625). The spec pin file goes 9 failed, 3 passed. The 3 that stay green are the ones that do not depend on the refinement: the control, the already-refused object, and the tie. Restored: blob equals HEAD,git diff HEADempty.pushExtensionMergedCbpFindings(findings, stack);call (blob1b241d17dfd0→5a4bafa4383f). The lint pin file goes 9 failed, 7 passed (the 7 assert silence or the declared control). Restored, verified the same way../stack.zod,./validate-security-posture.js), so no dist rebuild was owed.Gates:
node scripts/pm/dispatch-gates.mjs --commandsderived 91 families for this diff at4daa50d71. All 91 were run there with exit codes captured before any pipe, and--ranreconciles: 89 run (all exit 0), 2 NOT MEASURED.check:dual-build-cjs-loadsexited 3, PREREQUISITE NOT MET: it needs a full-workspacepnpm build, and only thecliclosure was built.check:type-check-debtwas not run: it is a whole-repo tsc census owed to CI's type-check job. These includecheck-adr-0087-registration --base origin/main(registeredcbp-extension-merged-master-refused, new here),check:api-surface(unchanged),check:authorable-surface,check:spec-changes,check:upgrade-guide,check:nul-bytesandcheck:spec-docblock-symbol-anchors. Local-only families:check:skill-frame-freshnessexit 0.check:dev-prereqsexit 1 is an unmet local precondition, not a verdict on this diff: 9 workspace packages outside thecliclosure are unbuilt.spec/distwas built from this head's spec source.pnpm --filter @objectstack/spec check:generatedreports all 14 artifacts current, so no generated projection moves.Stored metadata
No stored
sys_metadatarow can carry the shape: an extension is merged only when a stack or an artifact registers, andobject_extensionis not a registered metadata type (DEFAULT_METADATA_TYPE_REGISTRYhas no entry; the name appears only in the metadata plugin's artifact field map). An artifact built before 18 that carries the shape is refused at load (the artifact door andEnvironmentArtifactSchema.metadataparseObjectStackDefinitionSchema). That is the same posture as the directly declared shape sincecbp-master-relation-tiers-retired. The remedy is to rebuild from the corrected source.Consumer sweep
examples/**and the spec, lint, CLI and other package fixtures: noobjectExtensionsentry declares amaster_detailfield. The showcase's one extension (examples/app-showcase/src/data/extensions/account.extension.ts, onshowcase_account, which is notcontrolled_by_parent) addsselect,urlandnumberfields. Nothing to re-spell.Acceptance notes
os explain security-controlled-by-parent-ambiguous-relation(and-no-relation,rule-explanations.ts) still describes the declared object only. It now also fires for a merged one, and a sentence there would say so. It was left out of this diff's file surface. Carrier: none.validateSecurityPosturenow readsstack.packagesthroughpackagesOf, so a malformed (non-array)packagesthrowsINVALID_ARTIFACT_PACKAGESfrom this rule too. That matches its sibling readers (validate-translation-references.ts,validate-mapping-target-fields.ts,validate-object-references.ts) and the schema, which refuses the shape first.controlledByParentMasterRefusaland the fold are internal to@objectstack/spec, and exporting them would be a published widening. The parity table in the lint test holds them together.Generated by Claude Code