Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/security-posture-surface-reason-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
"@objectstack/lint": patch
---

fix(lint): `validateSecurityPosture`'s `surfaceReason` claimed a coverage the ADR-0094 gate does not give it — 1 of 13 rules (#7576)

`AUTHORING_RULES` is a registry of self-describing entries, and the
`validateSecurityPosture` block's self-describing field was the least
trustworthy thing in it. Its `surfaceReason` — the written answer to "why does
this rule not run at the runtime publish gate?" — read:

> Already gated at this surface by a DIFFERENT mechanism: plugin-security
> registers an ADR-0094 authoring gate on `object` (`registerAuthoringGate`)
> that enforces **the same OWD posture rules** on every runtime write. Running
> the linter here as well would double-report one refusal in two vocabularies.

Both halves were false, and they were load-bearing: twelve of the block's
thirteen rules were enforced at no runtime door while the registry said
otherwise, and the write path is the only door a Studio tenant, a REST `/meta`
client or an MCP/AI author has.

- **Coverage.** `object-posture-gate.ts` reads exactly `sharingModel` and
`externalSharingModel` through a local `OWD_WIDTH`, and never `fields`,
`permissions`, `books` or `data`. It covers ONE rule id —
`security-external-wider-than-internal`, its R2. Its other half, R1
(env-tighten-only, ADR-0086 D1), corresponds to no lint rule, so it is not
coverage in the other direction either.
- **Double-reporting.** It cannot happen, structurally rather than by luck.
`saveMetaItem` runs `assertRuntimeAuthoringRules` (this table, 422
`invalid_metadata`) *before* `runAuthoringGate` (the ADR-0094 gate, 403
`owd_external_wider`), and both refuse by throwing. The first to fire ends the
write, so an author earns one refusal either way.

The reason now states what was measured, including the two things that actually
block the move — a strictness rollout on `object` writes, and a per-write
snapshot that does not carry the collections three of the rules compare against.
`validate-security-posture.runtime-surface.test.ts` keeps those numbers
executable so the reason cannot rot back into prose.

**No behaviour change.** The block stays `surfaces: CLI_ONLY`, runs on the same
three commands, and finds the same things. One latent defect is corrected
alongside it: the runtime gate mapped the `seed` metadata type to a stack key
`seeds`, which no stack has and no rule reads (seeds live on `data`). Nothing
declares `seed` in `runtimeTypes` today, so the correction is inert now — it
stops the gate from silently judging an empty collection for whoever declares it
first.
74 changes: 70 additions & 4 deletions packages/lint/src/authoring-rules.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1069,17 +1069,83 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// a runtime enforcement point (fail-closed OWD default, canonical enum, anchor
// binding gate, vocabulary freeze), moving the failure from a runtime deny to
// an author-time fix-it. Per ADR-0049 this is not advisory security.
//
// [#7576] The `surfaceReason` below is MEASURED. Its predecessor was not, and
// was false in both halves — it read: "Already gated at this surface by a
// DIFFERENT mechanism: plugin-security registers an ADR-0094 authoring gate on
// `object` (`registerAuthoringGate`) that enforces the same OWD posture rules
// on every runtime write. Running the linter here as well would double-report
// one refusal in two vocabularies."
//
// - COVERAGE. `object-posture-gate.ts` reads exactly `sharingModel` and
// `externalSharingModel` through a local `OWD_WIDTH`, and never touches
// `fields`, `permissions`, `books` or `data`. Of the THIRTEEN rule ids this
// block carries it covers ONE — `security-external-wider-than-internal`
// (its R2). The gate's other half, R1 (env-tighten-only, ADR-0086 D1),
// corresponds to no lint rule at all, so it is not coverage in the other
// direction either. Twelve rules were enforced at no runtime door while
// this field said they were.
// - DOUBLE-REPORTING. It cannot happen, and not by luck: `saveMetaItem` runs
// `assertRuntimeAuthoringRules` (this table, 422 `invalid_metadata`) BEFORE
// `runAuthoringGate` (the ADR-0094 gate, 403 `owd_external_wider`), and
// both refuse by THROWING. The first to fire ends the write, so an author
// sees one refusal, never two. The stated cost of moving was imaginary; the
// reason it has not moved is the measured one below.
//
// Why the move is not taken HERE, measured rather than assumed (#7576 stage 1):
//
// - The four shipped stacks (showcase, CRM, todo, the `blank` template — 30
// objects, 10 permission sets, 1 book, 12 positions, 3 apps, 24 seeds) are
// CLEAN of `error` findings at both surfaces. No shipped app trips.
// - The PLATFORM's own runtime write path does. Declaring `object` here makes
// `security-owd-unset` refuse any object published without an OWD, and that
// is the shape the runtime create door actually emits: it turns 26 writes
// into 422s across 8 files of `@objectstack/metadata-protocol`'s own suite,
// and `METADATA_CREATE_SEEDS.object` — the authoritative minimal create body
// — carries no `sharingModel` either. That is a strictness rollout
// (#4001 pattern), not a registry-honesty fix, and its repair sites are in
// packages this card may not edit.
// - `permission` and `book` fail for a different, structural reason. The gate
// carries `objects` as resolution context and nothing else
// (`RuntimeStackContext`), so the three cross-collection rules judge a
// snapshot missing the collection they compare against. Measured: one
// simulated runtime write per shipped permission set produces 38
// `security-master-detail-ungranted` warnings where the same rule over the
// whole stack produces 4 — with one set in the snapshot, every detail
// object the tenant's OTHER sets grant reads as ungranted.
// `security-private-no-readscope` and `security-book-audience-unknown-set`
// fail identically. That is RUNTIME_NEEDS_FULL_SNAPSHOT (#4463 P2), and it
// is a snapshot change in the protocol package, not a `runtimeTypes` edit.
//
// The residue that IS ready: the two ADR-0091 seed rules
// (`security-grant-expired-at-authoring`, `security-delegation-missing-reason`)
// read only `stack.data[]` and cross the wall together as a whole sub-family,
// with zero measured trips. `runtime-gate.ts`'s `seed` stack key was corrected
// to `data` under this card so that slice is a one-line `runtimeTypes` edit
// when the rollout card takes it. `security-role-word` is deliberately NOT in
// that slice: it judges six collections, and wiring the two that need no
// snapshot would split ONE rule id across the wall — a door where a position
// named `sales_role` is refused and an object named `sales_role` is not, which
// is the #7220 failure this table already refuses to build.
{
name: 'validateSecurityPosture',
tier: 'gating',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-security-posture.ts',
surfaces: CLI_ONLY,
surfaceReason: 'Already gated at this surface by a DIFFERENT mechanism: plugin-security registers an ADR-0094 '
+ 'authoring gate on `object` (`registerAuthoringGate`) that enforces the same OWD posture rules on '
+ 'every runtime write. Running the linter here as well would double-report one refusal in two '
+ 'vocabularies. Consolidating the two onto this table is P2 (#4463), and is a merge, not a hole.',
surfaceReason:
'MEASURED, not inherited (#7576). The ADR-0094 `object` posture gate covers 1 of this block\'s 13 '
+ 'rule ids (`security-external-wider-than-internal`, its R2) — the previous reason claimed all of '
+ 'them, and its double-reporting worry was unreal: the two gates both THROW and this table runs '
+ 'first, so a write earns one refusal either way. The move is blocked by two other things. (a) '
+ 'Declaring `object` makes `security-owd-unset` refuse every OWD-less runtime object publish — 26 '
+ 'refusals across 8 files of metadata-protocol\'s own suite, and `METADATA_CREATE_SEEDS.object` '
+ 'carries no `sharingModel` — so it is a strictness rollout (#4001), not a wiring fix. (b) '
+ '`permission` / `book` need a second collection the per-write snapshot does not carry, and were '
+ 'measured inventing findings without it (38 vs 4 over the shipped corpus) — '
+ 'RUNTIME_NEEDS_FULL_SNAPSHOT, #4463 P2. The four shipped stacks themselves are clean at both '
+ 'surfaces; the ADR-0091 seed pair is snapshot-ready and crosses as a whole sub-family when (a) does.',
run: (stack) => validateSecurityPosture(stack),
},
// ADR-0105 D6 — the org tree is a REPORTING dimension. An RLS policy or
Expand Down
16 changes: 15 additions & 1 deletion packages/lint/src/runtime-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,21 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
dashboard: 'dashboards',
agent: 'agents',
hook: 'hooks',
seed: 'seeds',
// [#7576] `data`, NOT `seeds`. The metadata TYPE is `seed`; the stack KEY that
// holds seeds is `data` (`ObjectStackDefinitionSchema.data: z.array(SeedSchema)`)
// — a stack has no `seeds` key at all, and `PLURAL_TO_SINGULAR` declares no
// mapping onto one either.
//
// The wrong spelling was INERT rather than harmless, and it is the #4449 shape
// one surface over: the wiring guard asks only that a declared type HAS a
// mapping, never that the mapping names a key some rule reads. So it would
// have stayed green while the gate built `{ objects, seeds: [item] }` for
// every seed write and every rule reading `stack.data` saw nothing — wired,
// and running on nothing, with `rulesRun` reporting the rules as having run.
// Nothing declares `seed` in `runtimeTypes` today, so correcting it changes no
// behaviour now; it is corrected here, with the measurement that found it
// (#7576), rather than left for the rollout card to trip over.
seed: 'data',
};

/** Everything the gate needs from the host runtime to build a snapshot. */
Expand Down
Loading
Loading