From f64cee7b403f9543b7e855711d46fc7d878f61df Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 02:14:13 +0000 Subject: [PATCH 1/2] fix(lint): make validateSecurityPosture's surfaceReason a measured claim (#7576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `validateSecurityPosture` block's `surfaceReason` said the ADR-0094 `object` authoring gate "enforces the same OWD posture rules", so running the linter at the runtime publish surface too "would double-report one refusal in two vocabularies". Both halves were false. `object-posture-gate.ts` reads exactly `sharingModel` + `externalSharingModel` through a local `OWD_WIDTH` and never `fields` / `permissions` / `books` / `data`: it covers 1 of the block's 13 rule ids (`security-external-wider-than- internal`, its R2). And double-reporting cannot happen — `saveMetaItem` runs `assertRuntimeAuthoringRules` (422) before `runAuthoringGate` (403) and both refuse by throwing, so a write earns one refusal either way. The surface move itself is NOT taken here, on measurement rather than preference (#7576 stage 1): - the four shipped stacks (showcase, CRM, todo, the `blank` template) are clean of `error` findings at both surfaces — no shipped app trips; - but declaring `object` makes `security-owd-unset` refuse every OWD-less runtime object publish: 26 refusals / 48 failing tests across 8 files of `@objectstack/metadata-protocol`'s own suite, and `METADATA_CREATE_SEEDS.object` carries no `sharingModel` either. That is a strictness rollout (#4001), and its repair sites are outside this package; - `permission` / `book` fail differently: the gate carries `objects` and nothing else, so the three cross-collection rules judge a snapshot missing what they compare against — measured at 38 invented `security-master-detail-ungranted` warnings against the whole-stack run's 4. Also corrects a latent, currently-inert defect the measurement found: the gate mapped the `seed` metadata type to a stack key `seeds`, which no stack has and no rule reads (seeds live on `data`), so the first rule to declare the type would have been wired onto an empty collection. Refs #7576 --- ...ecurity-posture-surface-reason-measured.md | 46 +++++++++++ packages/lint/src/authoring-rules.ts | 74 +++++++++++++++++- packages/lint/src/runtime-gate.ts | 16 +++- ...e-security-posture.runtime-surface.test.ts | Bin 0 -> 12367 bytes 4 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 .changeset/security-posture-surface-reason-measured.md create mode 100644 packages/lint/src/validate-security-posture.runtime-surface.test.ts diff --git a/.changeset/security-posture-surface-reason-measured.md b/.changeset/security-posture-surface-reason-measured.md new file mode 100644 index 0000000000..26fc25cea2 --- /dev/null +++ b/.changeset/security-posture-surface-reason-measured.md @@ -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. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 66f9aa218f..e32266e04d 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1069,6 +1069,64 @@ 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', @@ -1076,10 +1134,18 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ 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 diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index fadb81b9f1..23b26fb213 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -74,7 +74,21 @@ const TYPE_TO_STACK_KEY: Readonly> = { 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. */ diff --git a/packages/lint/src/validate-security-posture.runtime-surface.test.ts b/packages/lint/src/validate-security-posture.runtime-surface.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d52dd66621bbf09ba71acfbacec6887deb16532c GIT binary patch literal 12367 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 MniSFm8iVcs08aN0Z~y=R literal 0 HcmV?d00001 From fb66048f086951f6854c3e5c8e936f593b622e86 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 12 Aug 2026 02:25:43 +0000 Subject: [PATCH 2/2] fix(lint): write the fingerprint separator as escape text, not the raw byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:nul-bytes` (which the ESLint job also runs) caught 3 raw 0x00 bytes in the new test file: the fingerprint helper mirrors `runtime-gate.ts`'s separator, and the escape sequence was materialised into the actual byte while copying it — the exact slip #4763 / #4890 / #5460 describe. The separator semantics were already right; only the spelling was wrong. Writing the escape text restores grep-ability (a raw NUL makes ripgrep treat the whole file as binary and return ZERO matches) and makes the mirror byte-identical to the gate's own line, which is what its drift guard wants. Refs #7576 --- ...e-security-posture.runtime-surface.test.ts | Bin 12367 -> 12382 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/lint/src/validate-security-posture.runtime-surface.test.ts b/packages/lint/src/validate-security-posture.runtime-surface.test.ts index d52dd66621bbf09ba71acfbacec6887deb16532c..eed84363cfd76750b223823d9e34e328eb53293d 100644 GIT binary patch delta 50 qcmX?~a4%tlDz9oxsR0nERHx~cXQURT)*|=?i6t3O?q+LV5oG|zWDtS? delta 35 ncmcbYa6Vy!Dz6xWN_CoEc}8kcYAu*qkXVvY%dpvtS40^A=kW{A