Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23210-extension-master-detail
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23210-extension-master-detail

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23210

Clause-②: no (narrowing)

What changes

ADR-0055 §1 says a controlled_by_parent object derives its master from exactly one master_detail field declared required: true. ObjectSchema and the security-posture lint refuse an object that declares anything else. Neither judged the object the runtime runs: at boot the registry folds every objectExtensions entry onto its target, so an extension could add a second required master, or demote the one. The stack passed defineStack, os validate and 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 on ObjectStackDefinitionSchema after applyApiEndpointGates and applyPermissionSetOwnerBindingGate. It calls the existing controlledByParentMasterRefusal (unchanged, still internal) on the merged fields, so the message is the declared object's own. defineStack throws STACK_SCHEMA_INVALID (422) carrying the issue.
  • @objectstack/lint: validateSecurityPosture gains a merged-shape pass that reports security-controlled-by-parent-ambiguous-relation or -no-relation at the extension. The two finding sites are now shared push helpers, so the merged and the declared finding are written once.
  • An ADR-0087 step-18 semantic entry, cbp-extension-merged-master-refused, with relevantWhen: 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) and ObjectExtensionSchema's own accept set.

The visibility reading implemented (H2 / H3)

  • Name resolution (H2): exact name equality, nothing looser. The registry keys an extension by its extend string verbatim (computeFQN is the identity), and getObject resolves 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 into cbpx_line. So a prefixed spelling is unjudged, and pinned so.
  • packages[] (H3): the artifact is the visibility boundary. With no packages[], the stack's objects are extended by its objectExtensions. With packages[], 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-level objectExtensions are not read there, because the engine registers a multi-package stack's bodies and the residual's objects, never its top-level extensions (ObjectQLPlugin's manifest.register, unclaimedTopLevelObjects). Why not one-body-only: composeStacks(…, { manifest: 'preserve' }) emits every definition inside packages[] only, so a per-body reading would leave the cross-package case, which is what objectExtensions is for, unjudged at os validate and os build. The lint's precedent is the same reach (validate-translation-references.ts and validate-mapping-target-fields.ts fold packages[].manifest.objectExtensions).
  • Unjudged, on purpose: a target the stack does not declare; a target two bodies declare (an ownership conflict the registry refuses on its own); a target already refused as declared (reported once, at the object); and a target on which two packages write one field key at the priority that decides it. In that last case the registry's winner is registration order (dependency-topological, resolveArtifactPackageOrder in @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 SchemaRegistry on this branch (scratch, not committed):

case merged cbpx_line
the card's extension adds other master_detail=[header:true, other:true]
extension redeclares header with required: false master_detail=[header:false]: the whole field is replaced by name
extension body carries sharingModel: 'private' sharingModel=controlled_by_parent: not carried
adder p200, fixer (other: text) p300 one master (the fixer wins)
adder p300, fixer p200 two masters (the adder wins)
adder p200, fixer p200 one master (the later-registered fixer wins: a tie is registration order)
one package, two entries for one target the later entry replaces the earlier
extender at p50, below the owner's 100 still folded on top of the base

The 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 fields of each extension that wrote a key which is a master_detail in the declaration or in the merged set: objectExtensions.0.fields, or packages.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.fields for two masters, objects.N.sharingModel for none. The merged issue's message is byte-equal to the control's (pinned).

The lint door (H4)

os validate parses with ObjectStackDefinitionSchema.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 keeps objectExtensions (a declared key). The lint pin still matters for the unparsed callers: os lint on 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 with main a7a135dc2 merged, after the seat's overlap notices for #23411 and #23412), unless noted. main has since moved to 05ab9ef89, 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 without packages/cli/dist, so cli was built and they were re-run: 2 files, 29 passed. The integration layer is declared to CI (no spawn entry or integration file touched).
  • Typecheck: @objectstack/spec green at f9744329d (the merge moved no spec source); @objectstack/lint green at 4daa50d71.

New pins: packages/spec/src/stack-cbp-extension-master.test.ts (12) and packages/lint/src/validate-security-posture-extension-merge.test.ts (16). Each refusal asserts the issue's code and 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 in validate-security-posture.test.ts table 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):

  • A1, spec: drop .superRefine(applyControlledByParentExtensionGate) (anchor 1 → 0, blob ecc31d4e3de3 → 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 HEAD empty.
  • A2, lint: delete the pushExtensionMergedCbpFindings(findings, stack); call (blob 1b241d17dfd0 → 5a4bafa4383f). The lint pin file goes 9 failed, 7 passed (the 7 assert silence or the declared control). Restored, verified the same way.
  • Both subjects resolve through source (./stack.zod, ./validate-security-posture.js), so no dist rebuild was owed.

Gates: node scripts/pm/dispatch-gates.mjs --commands derived 91 families for this diff at 4daa50d71. All 91 were run there with exit codes captured before any pipe, and --ran reconciles: 89 run (all exit 0), 2 NOT MEASURED. check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET: it needs a full-workspace pnpm build, and only the cli closure was built. check:type-check-debt was not run: it is a whole-repo tsc census owed to CI's type-check job. These include check-adr-0087-registration --base origin/main (registered cbp-extension-merged-master-refused, new here), check:api-surface (unchanged), check:authorable-surface, check:spec-changes, check:upgrade-guide, check:nul-bytes and check:spec-docblock-symbol-anchors. Local-only families: check:skill-frame-freshness exit 0. check:dev-prereqs exit 1 is an unmet local precondition, not a verdict on this diff: 9 workspace packages outside the cli closure are unbuilt. spec/dist was built from this head's spec source. pnpm --filter @objectstack/spec check:generated reports all 14 artifacts current, so no generated projection moves.

Stored metadata

No stored sys_metadata row can carry the shape: an extension is merged only when a stack or an artifact registers, and object_extension is not a registered metadata type (DEFAULT_METADATA_TYPE_REGISTRY has 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 and EnvironmentArtifactSchema.metadata parse ObjectStackDefinitionSchema). That is the same posture as the directly declared shape since cbp-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: no objectExtensions entry declares a master_detail field. The showcase's one extension (examples/app-showcase/src/data/extensions/account.extension.ts, on showcase_account, which is not controlled_by_parent) adds select, url and number fields. 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.
  • validateSecurityPosture now reads stack.packages through packagesOf, so a malformed (non-array) packages throws INVALID_ARTIFACT_PACKAGES from 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.
  • The merge reading is written twice, in the spec refinement and in the lint pass, because controlledByParentMasterRefusal and 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

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 14 documentable anchor(s).

25 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 05ab9ef895261d5c5f06ec2a5714eb71bd6b61d9.

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: master_detail (literal, 33 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 142 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 05ab9ef895261d5c5f06ec2a5714eb71bd6b61d9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 94ae00844bbeb3adf51e6d127b26cba7481f7460 — the merge of head 4daa50d71fad5e0cbe4fce2ec2f0b1dd4e436cd7 into base 05ab9ef895261d5c5f06ec2a5714eb71bd6b61d9, 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 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

⚠️ 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 05ab9ef895261d5c5f06ec2a5714eb71bd6b61d9 → 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

席位核对

ACCEPT · domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · head 4daa50d71 · 2026-10-14T09:59Z

It queues after one CONTRACT_REVIEW_TIER PASS on this head (the published-schema face: stack.zod.ts) and a green head.

1. Shape (read on GitHub, not from the report):

  • Draft, base main. Line 1 is Fixes #23210 and line 3 is Clause-②: no (narrowing); no other closing keyword sits beside a card number.
  • 8 files (+923 / -40). check-governed-merges --pr 23454 reads NOT governed (963 lines).
  • The changeset, read sentence by sentence against the diff:
    • It bumps @objectstack/spec and @objectstack/lint minor, BREAKING.
    • Its FROM → TO rows match the pins: the card's stack, the demoted master, and the prebuilt artifact.
    • Its remedy line and its "Unchanged" list match the code: unseen targets, an already-refused object reported once, the equal-priority tie in packages[], and no stored row.
    • The ADR-0087 disposition is registered cbp-extension-merged-master-refused, a new step-18 entry with relevantWhen: stack-declares objectExtensions.
    • "No export, signature or error code" holds: check:api-surface reports the surface unchanged, and the diff adds no export.

2. Premises, measured by the dev (the registry probe, not committed):

  • H1: fields merge by name, and a later writer replaces the whole field. sharingModel on an extend body is not carried.
  • H2: extend matches by exact name. cbpx__cbpx_line never merges into cbpx_line.
  • H3: with packages[], an extension in any body judges an object in any body, and top-level objectExtensions are not read, because the engine registers only the bodies' extensions.
  • H4: os validate parses before its rule table, so the card's stack stops at the parse. The lint's input keeps objectExtensions.
  • H5: the issue sits at the deciding extension's fields, with the declared control's message byte-equal.
  • Seat spot-read of applyControlledByParentExtensionGate:
    • extensions are sorted by priority, then by package position;
    • a target two bodies declare reads as null and is unjudged;
    • a base that is already refused is skipped;
    • a key two writers set at one priority skips the target;
    • the chain is applyApiEndpointGates → applyPermissionSetOwnerBindingGate → the new gate, as on main.

3. Gates:

  • Check-runs on 4daa50d71 at this act: 18 success, 6 skipped, 14 in progress, 0 failures. The queue waits for green on its own.
  • Locally, dispatch-gates --ran accounts for 91 families: 89 run, and 2 NOT MEASURED.
    • check:dual-build-cjs-loads exits 3, PREREQUISITE NOT MET. It is a Build Core step.
    • type-check-debt is owed to CI.
  • check:dev-prereqs exits 1 for missing dist/ outside the cli closure: a local precondition, not a verdict.
  • Ablations A1 (the .superRefine) and A2 (the lint call) turn their pin files red, 9 failed each; on restore, the blob equals HEAD.

4. Deviations, accepted:

5. Out of scope: two Acceptance notes, carrier: none, not filed. Both stay as Acceptance notes.

  • The os explain text (rule-explanations.ts) for the two security-controlled-by-parent-* rules describes the declared object only. Its remedy (keep one master, make every other parent a lookup) stays right for an extension. That is a missing sentence, not a wrong one, so filing gate ① does not take it.
  • A malformed non-array packages now throws INVALID_ARTIFACT_PACKAGES from the lint too, as its sibling readers do. That applies to unparsed input only.

6. Landing:

  • Tier S, through the ordinary queue, after the contract review PASS on this head and an all-green head.
  • The prebuilt-artifact row ("refused at load; rebuild it") widens the failure from one object that failed closed to the whole artifact's load. The changeset names it, no producer writes the shape today, and the contract review judges it.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4daa50d71fad5e0cbe4fce2ec2f0b1dd4e436cd7
Local-runs: none

Inputs: card #23210 (body; triage 6142996061, regrade 6163015687, claim 6163383778, os-dev-report 6165736147, the seat's pointer 6165795039); PR #23454 (body, 8-file list, comments 6165709047 and the seat record 6165781809, the net diff against main at the head: merge-base a7a135dc2, +923 / -40); the 39 check-runs on the head; committed source read with git show / git grep on origin/main (2d2e3ce43) and origin/claude/issue-23210-extension-master-detail (4daa50d71). The seat record was judged, not adopted. Nothing was built, run or re-run.

① Derived judgments

(a) The narrowing — right. ObjectStackDefinitionSchema gains applyControlledByParentExtensionGate, chained third after applyApiEndpointGates and applyPermissionSetOwnerBindingGate (stack.zod.ts:2037-2040), so defineStack (buildDefinedStack → safeParse → StackSchemaInvalidError, 422), os validate (validate.ts:349), os build, os verify, the lint scorer and the artifact load (metadata/src/plugin.ts:1301-1321) all refuse a stack whose visible objectExtensions leave a controlled_by_parent object with anything but exactly one required master_detail.

  • The merge is computed as the registry computes it. Read on origin/main: engine.ts:7779-7797 registers each manifest.objectExtensions entry as an extend contributor named ext.extend verbatim, namespace: undefined, priority = ext.priority ?? 200; registry.ts registerObject (extend branch) removes the SAME package's earlier extension of that object before pushing (one per package per target, the later entry replacing the earlier whole), then sorts the contributors by priority ascending (registry.ts:3111) — a stable sort, so equal priorities keep registration order; foldExtendersOntoDefinition → mergeObjectDefinitions does merged.fields = { ...base.fields, ...extension.fields } — by name, the whole field replaced — and the base is chosen by KIND (overlay ?? own), never by priority, so an extender at p50 still folds over the owner. The gate does exactly this: base = the target's own declaration (sharingModel is not an ObjectExtensionSchema key and mergeObjectDefinitions copies fields / validations / indexes / three labels only, so the merged object's sharingModel is the target's); byCarrier.set(carrier, …) keeps one entry per package, last wins; ordered is priority-ascending, carrier-ascending; merged[key] = field replaces whole; default 200 matches ObjectExtensionSchema.priority.default(200) and the engine's ?? 200. defineStack returns result.data (parsed), so what the runtime merges carries the same FieldSchema defaults the refinement judged (required omitted = false on both sides, and the runtime's resolveCbpRelation reads required === true, security-plugin.ts:8294): no verdict differs between the door and the boot. Package ORDER (resolveArtifactPackageOrder → resolvePluginOrder, dependency-topological, @objectstack/core) is the one input the parse does not have; the gate uses carrier position only to sequence evaluation and to DETECT a tie, never to decide one — judged under (b).
  • Reuse, not a copy. The refinement imports controlledByParentMasterRefusal from ./data/controlled-by-parent-master (the internal predicate ObjectSchema's own refinement and the v18 stored-row listing already share) and emits refusal.message unchanged; the spec pin compares it byte-equal with the directly declared control (stack-cbp-extension-master.test.ts). The predicate module is untouched and stays unexported. Right.
  • The issue path names the extension. path: [...extension.path, 'fields'] where extension.path is ['objectExtensions', i] or ['packages', j, 'manifest', 'objectExtensions', i]; blame = the last writer of every key that is a master_detail in the declaration or in the merged set, so the line that added the second master or demoted the one is the one located, and an innocent co-writer is not (pinned: "only the extension that wrote a master-relevant key is located"). Right.

(b) Visibility — right, with one blind spot wider than it needs to be (stated, not a FAIL).

  • Exact name equality with extend. computeFQN is the identity (registry.ts:172); the extension is keyed by ext.extend verbatim and an unmatched spelling opens an ownerless entry the registry skips with a warn ("has extenders but no owner"). The gate's targets.get(record.extend) is the same exact-string test; cbpx__cbpx_line is pinned unjudged at both doors. Right.
  • packages[]. The engine's manifest.register (objectql/src/plugin.ts:641-700) registers resolveArtifactPackageOrder(artifact) — the BODIES only — then unclaimedTopLevelObjects(artifact), which keeps residual items of type === 'object' alone. A multi-package stack's top-level objectExtensions therefore reach no registerApp and are never merged (they land in the metadata door's residual as object_extension items, a type with no registry entry — inert). So "an extension in any body judges an object in any body or a residual top-level object; top-level objectExtensions are not read" is the engine's registration, exactly. The residual's carrier is bodies.length, matching its registration after every body. Right. (That a top-level extension on a packages[] stack is silently inert is a pre-existing ADR-0078-shaped silence outside this card; noted under ③.)
  • The unjudged cases. (1) An unseen target: honest, stated in the changeset's Unchanged list and the entry's reason; the runtime's fail-closed refusal is the backstop, as the card's own Acceptance asks. (2) A target two bodies declare: registerObject(…, 'own') from a second package throws ObjectOwnershipConflictError (ADR-0029 D3) at registration, so no running stack carries the shape; stated in the code docblock and PR body (not in the changeset's list — a minor omission, not a hole). (3) A target already refused as declared: the per-object refinement refuses the stack whole at objects.N; reporting once is right. (4) Two writers tied at one priority: reachable (two packages extending one object at the default 200 with one key each), honest — the changeset says "two packages write one field at the same priority … not judged" and the runtime winner is resolvePluginOrder, which the spec cannot import without a second ordering mechanism (ADR-0130 D5 forbids one). Residual: the skip is per TARGET on a tie on ANY key — a tie on an innocent key (both packages write note) leaves a second master added by one of them unjudged, although every ordering yields the same master verdict there and no guess would be needed. Both doors skip identically, so parity holds, and the disclosure's literal wording covers it; the blind spot is honest but coarser than the ordering question requires. Follow-up (not owed here): narrow the tie guard to keys whose competing values differ in master-relevance.

(c) The lint door — parity holds; the rule is read once, the fold is written twice, and the drift guard is the parity table. validateSecurityPosture gains pushExtensionMergedCbpFindings, emitting through the two new shared push sites pushCbpNoRelation / pushCbpAmbiguousRelation, whose message and hint strings are byte-identical to the inline pushes the diff removes (compared line by line), so the declared and the merged finding are one wording; the finding sits at objectExtensions[i].fields (bracketed) with where: object "name". The fold mirrors the schema's (one per carrier, priority then carrier, whole-field replace, same tie guard, same blame), and the two doors are held to one verdict on 8 shapes with paths normalised (validate-security-posture-extension-merge.test.ts). The rule predicate itself is the lint's pre-existing cbpMasterCandidates (required === true, reference present) — the same arrangement the file's own docblock records for the declared object (parse, lint, runtime each read ADR-0055 §1 and tests hold them equal), so this diff adds no NEW second reading of the rule; what it adds is a second copy of the merge, declared in Acceptance note 3 with the reason (exporting the spec's fold or predicate is a published widening the card did not take). Reachability is real: os validate / os build / os verify stop at the parse (author-time-rules.ts), but os lint hands the normalized stack as the parsed tier without parsing (validate-form-layout.ts:112), the scorer lints after a failed parse, and the runtime write door runs the rule (CLI_AND_RUNTIME). On any stack that parses, both doors see the same values (priority 200 and required false defaults), so the parity table is the right guard; packagesOf and recordsOf are the lint's shared readers, not new ones. Right.

(d) The prebuilt-artifact consequence — right, disclosed, and the prescription reaches its reader by the same channels its sibling uses. Before: ObjectStackDefinitionSchema.parse at load passed and one object failed closed. After: the artifact is refused at load. That is the posture cbp-master-relation-tiers-retired already set for the same object declared directly, ADR-0055 §1 says fail closed, and accepting at load to fail closed at boot is exactly the declared-vs-enforced silence the card is about; no measured producer writes the shape. Changeset FROM / TO row 3 names it ("refused at load; rebuild it from the corrected source"), and the step-18 entry's reason and acceptanceCriteria say it in words. The entry's relevantWhen: { kind: 'stack-declares', keys: ['objectExtensions'] } is answered by stackDeclaresVerdict over the top level AND every packages[].manifest body (chain.ts), so os migrate meta lists the TODO for both authoring shapes and prints verify: = the acceptance criterion (migrate/meta.ts:802), and build-upgrade-guide prints it as "Done when". The loader's own refusal names the object, the objectExtensions.N.fields path and the field-level remedy, not the word "rebuild" — the generic posture of every refinement at that door; a 17-labelled artifact meets the ADR-0087 D1 handshake first, which names migrate meta. Acceptable.

(e) Public surface — none added. The diff's only new export is entry in 18.cbp-extension-merged-master-refused.ts, the migration-registry generator's file convention (read by the gitignored registry.ts, not a package entry). applyControlledByParentExtensionGate, VisibleObjectExtension, the three lint helpers and VisibleExtension are module-private; controlledByParentMasterRefusal stays internal; no signature moves; no error code is added (STACK_SCHEMA_INVALID is in the ledger at error-code-ledger.zod.ts:1508, INVALID_ARTIFACT_PACKAGES is packagesOf's existing code). Dev's check:api-surface reads unchanged; CI Type Check · source gates success; check:export-origins has no new origin to judge.

② Semver level

  • Changeset .changeset/23210-cbp-extension-merged-master.md: @objectstack/spec minor, @objectstack/lint minor, headed BREAKING — correct under the launch-window convention check-changeset-no-major enforces (a major is refused; breaking ships as minor in lockstep). Both packages narrow: the spec's accept set and the lint's findings.
  • ADR-0087 disposition registered cbp-extension-merged-master-refused: the right class. No lossless D2 mapping exists (which master stays, and whether a parent becomes a lookup, are the owner's decisions), so a D3 semantic entry with no conversion is correct; the id is new at this head and absent at the merge base, so registered (not already-registered) is the honest marker, and check-adr-0087-registration --base origin/main passed locally and in Lint & Repo Gates. The step-18 file carries id, surface, replacement, reason, acceptanceCriteria in the sibling entries' form, and relevantWhen on the one key an author can write the surface under (both shapes, per (d)). The reviewed enumeration semantic-relevance.test.ts moves 25 to 26 (18: 20 to 21) with the new key — the pin that makes an unenumerated entry red.
  • Clause-②: no (narrowing) — present in the claim, PR body line 3 and the changeset, and true: no key is added to any published payload.

③ Boundary flags

  • Dev report 6165736147: open_questions: []; premise_still_valid: true. Every flag read.
  • Out-of-scope note 1, os explain text (rule-explanations.ts) for the two security-controlled-by-parent-* rules describing the declared object only — correctly a note, not a defect owed here. Its rule statement and remedy ("keep master_detail on the ONE master, every other parent a lookup") stay true for a merged object; the one sentence that no longer covers the merged case ("ObjectSchema.create() makes every master_detail on such an object required") is about the declared object and remains true of it, since ObjectExtensionSchema forces nothing and an extension's master_detail without required: true is not a second master at the parse, the lint or the runtime (all three read required === true). Missing sentence, not a false one; carrier none is acceptable, and a one-line follow-up on the owning seat's lane is the right place.
  • Out-of-scope note 2, validateSecurityPosture now throwing INVALID_ARTIFACT_PACKAGES on a present non-array packages — correctly a note. Ruling A on finding(spec): the option-B readers disagree with @objectstack/core about whether a non-array packages is a refusal #15293 makes every reader refuse that shape; validate-object-references / validate-translation-references / validate-mapping-target-fields already throw it through the same packagesOf on the same unparsed intake, runAuthoringRules catches nothing, so the door's behaviour on that input is unchanged; the schema refuses it first on every parsed path.
  • Deviations, as the seat accepted: the two files beyond the claim's surface are the enumeration pin (semantic-relevance.test.ts) and the validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #5017 receiver tables (validate-security-posture.test.ts) that the entry and the new reads make red if untouched — forced by existing pins, not scope growth; the two off-lock pin runs were re-measured under the lock and no verdict rests on them; the model-free commit trailers follow AGENTS.md over the harness reminder, as AGENTS.md itself directs. Accepted.
  • Observed, not owed here: a top-level objectExtensions on a packages[] stack is inert at both boot doors (never registered), a pre-existing silence the card does not name; and the tie guard's breadth under ①(b). Neither admits the card's defect shape through a stated-judged target; both are candidates for a card.
  • Check-runs on 4daa50d71, as found at this read (39): 25 success — Lint & Repo Gates, Build Core, Governed Surface Queue Guard, Check Changeset, Type Check · source gates, Type Check · consumer gates, Lint · ESLint, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Lint · scoped · query-options erasure, Spec property liveness, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Dogfood Regression Gate, Auto Label, filter, and the four claim / single-writer / part-of guards; 6 skipped — Console Pin Gate, Temporal Conformance (live PG + MySQL), Build Docs, Dogfood Verify CLI, Dogfood Regression Gate (matrix), Packed-tarball smoke (opt-in); 8 in progress — Test Core (1/6) through (6/6), Type Check · workspace, Type Check · debt ledger; 0 failures. Test Core is a required context and is not yet a pass; the queue waits for it on its own. This record judges the contract on this head; it is not a green stamp.

Implemented-by: claude/issue-23210-extension-master-detail
Reviewed-by: session_01YRp5z4kK6vgYkrdH97ztsy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 10:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 10:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 4e0838d Oct 14, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23210-extension-master-detail branch October 14, 2026 10:52
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