Repository navigation
Commit 4e0838d
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)
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](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 08ea7e3 commit 4e0838d
8 files changed
Lines changed: 923 additions & 40 deletions
File tree
- .changeset
- packages
- lint/src
- spec/src
- migrations
- entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1463 | 1463 | | |
1464 | 1464 | | |
1465 | 1465 | | |
| 1466 | + | |
| 1467 | + | |
| 1468 | + | |
| 1469 | + | |
| 1470 | + | |
| 1471 | + | |
1466 | 1472 | | |
1467 | 1473 | | |
1468 | 1474 | | |
| |||
1472 | 1478 | | |
1473 | 1479 | | |
1474 | 1480 | | |
1475 | | - | |
| 1481 | + | |
| 1482 | + | |
| 1483 | + | |
| 1484 | + | |
| 1485 | + | |
1476 | 1486 | | |
1477 | 1487 | | |
1478 | 1488 | | |
| |||
1558 | 1568 | | |
1559 | 1569 | | |
1560 | 1570 | | |
| 1571 | + | |
| 1572 | + | |
| 1573 | + | |
| 1574 | + | |
| 1575 | + | |
| 1576 | + | |
| 1577 | + | |
| 1578 | + | |
| 1579 | + | |
| 1580 | + | |
| 1581 | + | |
| 1582 | + | |
| 1583 | + | |
| 1584 | + | |
| 1585 | + | |
| 1586 | + | |
| 1587 | + | |
| 1588 | + | |
| 1589 | + | |
| 1590 | + | |
| 1591 | + | |
| 1592 | + | |
1561 | 1593 | | |
1562 | 1594 | | |
1563 | 1595 | | |
| |||
1612 | 1644 | | |
1613 | 1645 | | |
1614 | 1646 | | |
| 1647 | + | |
| 1648 | + | |
| 1649 | + | |
1615 | 1650 | | |
1616 | 1651 | | |
1617 | 1652 | | |
| |||
0 commit comments