From 277c9352bfd314f158a34c2b485063966297b812 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 00:51:46 +0000 Subject: [PATCH 1/6] feat(lint): declare object in validateSecurityPosture runtimeTypes; retire plugin-gate R2 external-wider arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #8310 maintainer ruling (option A): an authored OWD is required at the runtime object door — an object publish with no authored sharingModel is refused with the 422 lint envelope (security-owd-unset). The plugin gate's R2 owd_external_wider arm is retired as duplicate of the lint door; R1 (env-tighten-only) stays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- packages/lint/src/authoring-rules.ts | 49 ++--- ...e-security-posture.runtime-surface.test.ts | 179 ++++++++++-------- .../src/object-posture-gate.test.ts | 78 ++++---- .../src/object-posture-gate.ts | 50 +++-- 4 files changed, 176 insertions(+), 180 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 7606d973e1..677a0a75a1 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1137,38 +1137,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // `security-master-detail-ungranted` per-write vs 4 whole-stack, // PR #7886). `RuntimeStackContext` now carries `permissions`/`books` in // BOTH differential passes and `TYPE_TO_STACK_KEY` maps both types. - // - #8310 (this state): `runtimeTypes` gains `permission` + `book`. - // `object` measured DIRTY and stays behind — see below. - // - // Why `object` is still not declared, re-measured on the #8308-repaired - // tree rather than inherited (#4001: zero breakage is demonstrated, never - // assumed). The OLD blocker is genuinely gone: with `object` declared, the - // full `@objectstack/metadata-protocol` suite passes (the 26-refusal - // measurement predates #8308's `METADATA_CREATE_SEEDS.object` repair) and a - // replay of every shipped-corpus object through the real gate refuses - // nothing. But one package over the same declaration still breaks the - // platform's own write paths — measured on this exact tree: - // - // - `@objectstack/objectql`: 83 tests across 13 files fail, every one - // `security-owd-unset` (85 refusals) — the suites publish objects with - // no authored `sharingModel` through `saveMetaItem`. - // - `@objectstack/rest`: 12 tests across 3 files — the same owd-unset - // class, PLUS two genuine CONTRACT collisions no fixture edit can - // honestly settle: `meta-object-owd-gate.test.ts` pins #7674's ADR-0094 - // 403 `owd_external_wider` door, which this 422 gate now PREEMPTS for - // the same defect (`saveMetaItem` runs this table first), and it pins - // that a write with NO OWD keys at all SAVES (ADR-0094 reads absence as - // the D1 `private` default) — which `security-owd-unset` exists to - // refuse (absence must be an authored decision). - // - // So declaring `object` is not a wiring fix and not even only fixture - // repair in two packages outside this card's surface: it is a decision - // about which door answers for OWD defects (403 ADR-0094 vocabulary vs - // 422 lint vocabulary) and whether an unauthored OWD refuses at runtime. - // That decision is escalated on #8310; until it is ruled, `object` stays - // undeclared and the pins in - // `validate-security-posture.runtime-surface.test.ts` record both what - // WOULD happen (via the gate's own snapshot builder) and that it does not. + // - #8310 slice 1: `runtimeTypes` gains `permission` + `book` (PR #8546). + // `object` measured DIRTY on that tree and was escalated, not forced. + // - #8310 slice 2 (this state): `object` crosses under the maintainer + // ruling recorded on #8310 (2026-08-13, 「接受你的全部建议」): an + // authored OWD is REQUIRED at the runtime object door — an object + // publish with no authored `sharingModel` is refused with the 422 lint + // envelope (`security-owd-unset`); absence is not a decision. The ~16 + // objectql/rest suite files that relied on OWD-less publishes were + // repaired honestly (fixtures author their posture), and + // `meta-object-owd-gate.test.ts` re-pins the door ORDER: this table + // answers first (`saveMetaItem` runs it before `runAuthoringGate`), the + // ADR-0094-seam 403 doors answer for what passes lint. The same ruling + // retired the plugin gate's R2 `owd_external_wider` arm as a duplicate + // of this door (R1 env-tighten-only STAYS — no lint rule covers it); + // see `object-posture-gate.ts` and the ADR-0094 amendment. // // `security-role-word` is NOT in this entry any more — that is what the // `validateSecurityRoleWord` entry below records. It judges six collections @@ -1195,7 +1178,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ALL, source: 'packages/lint/src/validate-security-posture.ts', surfaces: CLI_AND_RUNTIME, - runtimeTypes: ['seed', 'permission', 'book'], + runtimeTypes: ['seed', 'permission', 'book', 'object'], run: (stack) => validateSecurityPosture(stack), }, // [ADR-0090 D3 / #8310] The vocabulary freeze, split out of 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 44c1d025cb..f9da08f220 100644 --- a/packages/lint/src/validate-security-posture.runtime-surface.test.ts +++ b/packages/lint/src/validate-security-posture.runtime-surface.test.ts @@ -23,40 +23,40 @@ // `permissions`/`books` in BOTH differential passes and `TYPE_TO_STACK_KEY` // maps both types, killing the measured phantom findings (38-vs-4, // PR #7886) that made crossing `permission`/`book` unshippable. -// - #8310 (this state): `runtimeTypes` gains `permission` + `book`, and -// `security-role-word` is split into its own CLI-only entry +// - #8310 slice 1 (PR #8546): `runtimeTypes` gains `permission` + `book`, +// and `security-role-word` is split into its own CLI-only entry // (`validateSecurityRoleWord`) so it stays behind WHOLE rather than cross // for a strict subset of the six collections it judges — the #7220 // discipline: one rule id sits on ONE side of the wall. `object` measured -// DIRTY on this exact tree and stays behind, escalated on #8310: the -// #8308 repair really did clean `@objectstack/metadata-protocol` (full -// suite green with `object` declared) and the shipped corpus replays -// clean, but `@objectstack/objectql` (83 tests / 13 files, all -// `security-owd-unset`) and `@objectstack/rest` (12 tests / 3 files, -// owd-unset + external-wider — including #7674's pins that the ADR-0094 -// 403 `owd_external_wider` door answers, which this 422 gate would -// preempt, and that a write with NO OWD keys saves) still refuse. Which -// door answers, and whether an unauthored OWD refuses at runtime, is a -// contract decision — not a fixture repair. +// DIRTY on that tree (objectql 83 tests / 13 files, all +// `security-owd-unset`; rest 12 / 3) and was escalated, not forced. +// - #8310 slice 2 (this state): `object` crosses under the maintainer +// ruling (2026-08-13, 「接受你的全部建议」): an authored OWD is REQUIRED +// at the runtime object door — an OWD-less object publish is refused with +// the 422 lint envelope (`security-owd-unset`); absence is not a +// decision. The red suite files were repaired honestly (fixtures author +// their posture), `meta-object-owd-gate.test.ts` re-pins the door ORDER +// (this 422 table answers first; the ADR-0094-seam 403 R1 door answers +// for what passes lint), and the same ruling retired the plugin gate's +// R2 `owd_external_wider` arm as this door's duplicate. // // ## What each case is evidence FOR // // 1. `the mirror still matches the real gate (flow)` — non-vacuity for // `wouldGateAdd` (builder-vs-gate parity on a wired type). -// 2. The crossing pins: `permission`/`book` now reach this block at the -// REAL gate; `object` and `position`/`app` reach no rule (the escalated -// residue and role-word's residue respectively — see 3 and 4). +// 2. The crossing pins: `seed`/`permission`/`book`/`object` all reach this +// block at the REAL gate; `position`/`app` reach no rule (role-word's +// residue — see 3). // 3. `security-role-word` stays behind WHOLE: its entry is CLI-only, no // runtime-gated type reaches it, and the door does NOT refuse a // `role_manager`-named permission set for it — while the CLI still // refuses exactly what it refused before the split (both entries run on // all three commands; the union of their findings is the pre-split set). -// 4. The `object` residue, kept executable through the gate's OWN snapshot -// builder: an OWD-less object write WOULD be refused, the platform's own -// create seed WOULD be clean (#8308's repair, re-measured), and a clean -// write is not blamed for the context's pre-existing defects — so the -// day the escalation rules, the flip is one array element plus flipping -// these pins to the real gate. +// 4. The `object` crossing, at the real gate: an OWD-less object publish IS +// refused (`security-owd-unset` — the ruled strictness), the platform's +// own create seed is clean (#8308's repair), a clean write is not blamed +// for the context's pre-existing defects, and the system-object boundary +// of the retired R2 arm is pinned as deliberate. // 5. The #8309 agreement pins, upgraded to the REAL gate now that // `permission`/`book` are declared: the write agrees with the // whole-stack verdict against the full context, and the pre-#8309 @@ -175,16 +175,16 @@ describe('validateSecurityPosture at the runtime publish surface (#7576 → #830 expect(stackKeyForType('flow')).toBe('flows'); }); - it('[#8310] permission / book now cross — the measured half of the flip, on the whole 12-rule entry', () => { - // The registration this card makes: the `validateSecurityPosture` entry - // (12 rule ids — `security-role-word` is its own entry now, see below) - // declares `permission` and `book` beside `seed`. Both measured ZERO - // refusals across the full `@objectstack/metadata-protocol` suite, the - // `@objectstack/objectql` and `@objectstack/rest` suites, and a replay of - // every shipped-corpus permission set and book through the real gate. + it('[#8310] seed / permission / book / object all cross — the completed flip, on the whole 12-rule entry', () => { + // The registration the #7891 programme was for: the + // `validateSecurityPosture` entry (12 rule ids — `security-role-word` is + // its own entry now, see below) declares all four mapped types. + // `permission`/`book` measured ZERO refusals when they crossed (PR + // #8546); `object` crosses under the #8310 maintainer ruling with the + // red suites repaired honestly (fixtures author their `sharingModel`). expect(ENTRY.surfaces).toEqual(['cli', 'runtime-publish']); - expect(ENTRY.runtimeTypes).toEqual(['seed', 'permission', 'book']); - for (const type of ['seed', 'permission', 'book']) { + expect(ENTRY.runtimeTypes).toEqual(['seed', 'permission', 'book', 'object']); + for (const type of ['seed', 'permission', 'book', 'object']) { expect( runtimeAuthoringRulesFor(type).map((r) => r.name), `'${type}' writes must reach this block at the door`, @@ -193,26 +193,46 @@ describe('validateSecurityPosture at the runtime publish surface (#7576 → #830 } }); - it("[#8310] object still reaches no rule — measured dirty, escalated, NOT silently crossed", () => { - // The re-measurement this card ran (#4001: demonstrated, never assumed): - // with `object` declared, `@objectstack/metadata-protocol` is fully green - // (#8308's seed repair killed the old 26-refusal blocker) and the corpus - // replays clean — but `@objectstack/objectql` fails 83 tests across 13 - // files (every one `security-owd-unset`) and `@objectstack/rest` fails 12 - // across 3, including #7674's pins of the ADR-0094 403 - // `owd_external_wider` door this 422 gate would preempt, and of "a write - // with NO OWD keys at all saves". Declaring `object` is therefore a - // contract decision (which door answers; is an unauthored OWD a refusal), - // escalated on #8310. A future declaration is a deliberate edit to THIS - // test, with that decision in hand. - expect(ENTRY.runtimeTypes).not.toContain('object'); - expect(runtimeAuthoringRulesFor('object')).toEqual([]); + it('[#8310] an OWD-less object publish is REFUSED at the real gate — absence is not a decision', () => { + // The object crossing pin, same shape as the permission/book pins: the + // maintainer-ruled strictness itself. An object write with no authored + // `sharingModel` is refused by `security-owd-unset` at the REAL runtime + // gate — the body `METADATA_CREATE_SEEDS.object` carried before #8308. const real = runRuntimeAuthoringRules({ type: 'object', - item: { name: 'new_object', label: 'New Object', fields: {} }, // would trip owd-unset if gated + item: { name: 'new_object', label: 'New Object', fields: {} }, + }); + expect(real.errors.map((f) => f.rule)).toEqual([SECURITY_OWD_UNSET]); + expect(real.errors[0].severity).toBe('error'); + expect(real.errors[0].path).toBe('objects[0].sharingModel'); + expect(real.rulesRun).toContain('validateSecurityPosture'); + + // And the same write with the OWD authored is clean — the refusal is + // about the missing decision, not about object writes as such. + const clean = runRuntimeAuthoringRules({ + type: 'object', + item: { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }, + }); + expect(clean.errors).toEqual([]); + expect(clean.advisories).toEqual([]); + }); + + it('[#8310] the R2-retirement boundary: a SYSTEM object with unset OWD is not refused at this door', () => { + // The one active-path shape the retired plugin-gate R2 refused that this + // door does not: a system object (`isSystem` / `sys_*`) with no authored + // `sharingModel` and an explicit external side. Deliberate, not a gap: + // `security-owd-unset` exempts system objects because their runtime + // default is PUBLIC (`effectiveSharingModel`, plugin-sharing) — R2's + // hardcoded private baseline misread that default, so its refusal here + // was a false positive, which is exactly why the #8310 ruling could + // retire it as duplicate. A deliberate edit to THIS pin is required to + // change that boundary. + const real = runRuntimeAuthoringRules({ + type: 'object', + item: { name: 'sys_probe', label: 'Probe', externalSharingModel: 'public_read', fields: {} }, }); expect(real.errors).toEqual([]); - expect(real.rulesRun, 'no rule runs for an object write — clean by absence, not by verdict').toEqual([]); + expect(real.rulesRun).toContain('validateSecurityPosture'); }); it("[#8310] position / app still reach no rule — role-word's residue, not an oversight", () => { @@ -292,51 +312,50 @@ describe('validateSecurityPosture at the runtime publish surface (#7576 → #830 expect(stackKeyForType('seed')).toBe('data'); }); - it('an OWD-less object write WOULD be refused — the strictness the escalation is about', () => { - // The body `METADATA_CREATE_SEEDS.object` carried BEFORE #8308: name, - // label, fields — and no `sharingModel`. Kept literal as the would-be - // refusal's positive control, through the gate's OWN snapshot builder - // (the type is not declared, so the real gate cannot be asked). + it('the builder mirror agrees with the real gate on the object refusal — parity, not duplication', () => { + // `wouldGateAdd` (the gate's OWN snapshot builder, this block in + // isolation) and the real gate must attribute the same finding to the + // same OWD-less write, now that `object` is declared and both are + // askable. const added = wouldGateAdd('object', { name: 'new_object', label: 'New Object', fields: {} }); expect(added.map((f) => f.rule)).toEqual([SECURITY_OWD_UNSET]); - expect(added[0].severity).toBe('error'); - expect(added[0].path).toBe('objects[0].sharingModel'); - - // And the same write with the OWD authored would be clean, so the refusal - // is about the missing decision and not about object writes as such. - expect( - wouldGateAdd('object', { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }), - ).toEqual([]); + const real = runRuntimeAuthoringRules({ + type: 'object', + item: { name: 'new_object', label: 'New Object', fields: {} }, + }); + expect(real.errors.map((f) => f.rule)).toEqual(added.map((f) => f.rule)); }); - it('[#8308] the REAL create seed would be clean at this gate — blocker A repaired, re-measured', () => { + it('[#8308] the REAL create seed is clean at the real gate — blocker A repaired, still holding', () => { // The platform's own minimal create body AUTHORS its OWD // (`sharingModel: 'private'` — the measured runtime default, ADR-0090 D1 / - // `effectiveSharingModel` in plugin-sharing), so the gate the escalation - // would register for `object` refuses nothing on the platform's own - // create path — the fallout that was 26 refusals across 8 suite files - // before #8308 is measured ZERO on the repaired tree. Consumed from the - // seed registry, not re-spelled, so a seed regression re-opens THIS pin - // rather than passing silently. + // `effectiveSharingModel` in plugin-sharing), so the gate this card + // registered for `object` refuses nothing on the platform's own create + // path — the fallout that was 26 refusals across 8 suite files before + // #8308 is measured ZERO. Consumed from the seed registry, not + // re-spelled, so a seed regression re-opens THIS pin rather than passing + // silently. const seed = getMetadataCreateSeed('object') as AnyRec; expect(seed.sharingModel).toBe('private'); - expect(wouldGateAdd('object', seed)).toEqual([]); + const real = runRuntimeAuthoringRules({ type: 'object', item: seed }); + expect(real.errors).toEqual([]); + expect(real.rulesRun).toContain('validateSecurityPosture'); }); - it('a clean object write would not be blamed for the context\'s pre-existing defects', () => { - // The differential's D4 promise, pre-verified for the day `object` - // crosses: a clean object write against a universe that ALREADY carries - // an OWD-less object must not inherit that finding — it fires identically - // in both passes and cancels. Without this, one legacy row would block - // every future publish. + it('a clean object write is not blamed for the context\'s pre-existing defects', () => { + // The differential's D4 promise, now live at the real gate: a clean + // object write against a universe that ALREADY carries an OWD-less + // object must not inherit that finding — it fires identically in both + // passes and cancels. Without this, one legacy row would block every + // future publish. const legacyContext = [{ name: 'legacy_thing', label: 'Legacy', fields: {} }]; // no sharingModel - expect( - wouldGateAdd( - 'object', - { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }, - { objects: legacyContext }, - ), - ).toEqual([]); + const real = runRuntimeAuthoringRules({ + type: 'object', + item: { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }, + context: { objects: legacyContext }, + }); + expect(real.errors).toEqual([]); + expect(real.advisories).toEqual([]); }); it('[#8309→#8310] a permission-set write AGREES with the whole-stack verdict at the real gate', () => { diff --git a/packages/plugins/plugin-security/src/object-posture-gate.test.ts b/packages/plugins/plugin-security/src/object-posture-gate.test.ts index ddfaf779af..cee2367da4 100644 --- a/packages/plugins/plugin-security/src/object-posture-gate.test.ts +++ b/packages/plugins/plugin-security/src/object-posture-gate.test.ts @@ -1,8 +1,15 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // #3050 — OWD posture authoring gate: env-tighten-only over packaged -// declarations (ADR-0086 D1) + external ≤ internal (ADR-0090 D11), enforced -// on the runtime write path (previously CLI-lint-only). +// declarations (ADR-0086 D1), enforced on the runtime write path. +// +// R2 (`owd_external_wider`, external ≤ internal) is RETIRED from this gate — +// maintainer ruling on #8310: since `validateSecurityPosture` declares +// `object` in `runtimeTypes`, the runtime lint door refuses the same defect +// as a 422 (`security-external-wider-than-internal`, and `security-owd-unset` +// for the unset-internal shape) BEFORE this gate runs. The retirement pins +// below keep that removal deliberate; the 422-door coverage is pinned +// end-to-end in `packages/rest/src/meta-object-owd-gate.test.ts`. import { describe, it, expect } from 'vitest'; import { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js'; @@ -15,51 +22,38 @@ const base = (over: Partial[0]> = {}) => ({ ...over, }); -describe('R2 — external ≤ internal (ADR-0090 D11)', () => { - it('rejects external wider than internal', () => { +describe('R2 retirement (#8310) — this gate no longer judges external ≤ internal', () => { + it('passes an external-wider pair on a non-artifact body — the 422 lint door owns this refusal now', () => { + // Before #8310 this threw 403 `owd_external_wider`. The refusal is NOT + // gone from the platform: `saveMetaItem` runs the runtime lint table + // first, and an active-state publish of this body answers 422 + // `security-external-wider-than-internal` before this gate is reached. expect(() => objectPostureGate(base({ body: { sharingModel: 'public_read', externalSharingModel: 'public_read_write' }, - }))).toThrowError(/owd_external_wider/); - }); - - it('rejects explicit external on an OWD-less body (internal defaults to private, ADR-0090 D1)', () => { - expect(() => objectPostureGate(base({ - body: { externalSharingModel: 'public_read' }, - }))).toThrowError(/owd_external_wider/); - }); - - it('accepts external equal to internal', () => { - expect(() => objectPostureGate(base({ - body: { sharingModel: 'public_read', externalSharingModel: 'public_read' }, - }))).not.toThrow(); - }); - - it('accepts external tighter than internal', () => { - expect(() => objectPostureGate(base({ - body: { sharingModel: 'public_read_write', externalSharingModel: 'private' }, }))).not.toThrow(); }); - it('skips ordering when either side is controlled_by_parent (inherits master pair)', () => { - expect(() => objectPostureGate(base({ - body: { sharingModel: 'controlled_by_parent', externalSharingModel: 'public_read' }, - }))).not.toThrow(); + it('passes an explicit external on an OWD-less body — 422 `security-owd-unset` answers upstream', () => { + // R2 resolved the unset internal to `private` and refused. The ruled door + // is stricter about the CAUSE: an unauthored `sharingModel` is itself the + // refusal (absence is not a decision) — and for system objects, where + // owd-unset deliberately does not apply, the runtime default is PUBLIC + // (`effectiveSharingModel`), so R2's private baseline was a false + // premise there, not protection. expect(() => objectPostureGate(base({ - body: { sharingModel: 'public_read', externalSharingModel: 'controlled_by_parent' }, + body: { externalSharingModel: 'public_read' }, }))).not.toThrow(); }); - it('accepts a body with no posture fields at all', () => { - expect(() => objectPostureGate(base({ body: { name: 'crm_account', fields: {} } }))).not.toThrow(); - }); - - it('carries 403 + code on the error', () => { - try { - objectPostureGate(base({ body: { sharingModel: 'private', externalSharingModel: 'public_read' } })); - expect.unreachable('should have thrown'); - } catch (e: any) { - expect(e.status).toBe(403); - expect(e.code).toBe('owd_external_wider'); + it('still accepts every legal pair (nothing new is refused by the retirement)', () => { + for (const body of [ + { sharingModel: 'public_read', externalSharingModel: 'public_read' }, + { sharingModel: 'public_read_write', externalSharingModel: 'private' }, + { sharingModel: 'controlled_by_parent', externalSharingModel: 'public_read' }, + { sharingModel: 'public_read', externalSharingModel: 'controlled_by_parent' }, + { name: 'crm_account', fields: {} }, + ]) { + expect(() => objectPostureGate(base({ body }))).not.toThrow(); } }); }); @@ -145,10 +139,12 @@ describe('registerObjectPostureGate wiring', () => { expect(registerObjectPostureGate(protocol)).toBe(true); const gate = gates.get('object')!; expect(gate).toBeTypeOf('function'); + // R1 through the registered seam (R2 is retired, so the wiring pin uses + // the surviving rule: widening a packaged declaration). await expect(async () => gate({ - type: 'object', name: 'crm_account', body: { sharingModel: 'private', externalSharingModel: 'public_read' }, - isArtifactBacked: false, - })).rejects.toThrowError(/owd_external_wider/); + type: 'object', name: 'crm_account', body: { sharingModel: 'public_read_write' }, + isArtifactBacked: true, declaredBody: { sharingModel: 'private' }, + })).rejects.toThrowError(/owd_widening_forbidden/); }); it('feature-detects: returns false on a protocol without the seam', () => { diff --git a/packages/plugins/plugin-security/src/object-posture-gate.ts b/packages/plugins/plugin-security/src/object-posture-gate.ts index 4ef5fb09dd..2237ab1de3 100644 --- a/packages/plugins/plugin-security/src/object-posture-gate.ts +++ b/packages/plugins/plugin-security/src/object-posture-gate.ts @@ -6,10 +6,8 @@ * Registered on the metadata protocol's pre-persistence authoring-gate seam * (ADR-0094 addendum; `registerAuthoringGate`), so it fires on EVERY * runtime-authored object body — Studio drafts, direct REST saves, AI - * builders — regardless of which HTTP surface produced the write. It closes - * the two posture rules that were previously CLI-lint-only - * (`packages/lint/src/validate-security-posture.ts` runs at `os compile` / - * `os lint`, never on `saveMetaItem`): + * builders — regardless of which HTTP surface produced the write. It carries + * ONE rule: * * - **R1 — env-tighten-only (ADR-0086 D1, ADR-0049).** An environment write * over a PACKAGED object (artifact-backed — reachable only via the @@ -17,11 +15,24 @@ * `allowOrgOverride:false`) may not set `sharingModel` / * `externalSharingModel` WIDER than the packaged declaration. Widening * legitimately = author it in the package source and publish (ADR-0090 - * D7), never an env overlay. - * - **R2 — external ≤ internal (ADR-0090 D11).** Any object write must keep - * `externalSharingModel` no wider than `sharingModel`. Previously stated - * only in `.describe()` prose (`object.zod.ts`) and the lint rule - * `SECURITY_EXTERNAL_WIDER`. + * D7), never an env overlay. No lint rule covers this comparison — it + * needs the packaged DECLARATION, a deployment fact only this seam holds. + * + * **R2 (`owd_external_wider`, external ≤ internal, ADR-0090 D11) is RETIRED + * from this gate** — maintainer ruling on #8310 (2026-08-13): duplicate of + * the runtime lint door. Since `AUTHORING_RULES` declares `object` in + * `validateSecurityPosture`'s `runtimeTypes`, every active-state object + * publish is judged by the D7 lint block FIRST (`saveMetaItem` runs + * `assertRuntimeAuthoringRules` before `runAuthoringGate`), which refuses + * the same defect as 422 `security-external-wider-than-internal` — and + * refuses an unset `sharingModel` outright (422 `security-owd-unset`: + * absence is not a decision), which is stricter than R2's private-default + * comparison. R2's only non-shadowed refusals were false positives: a + * system object (`isSystem` / `sys_*`) with no authored `sharingModel` is + * effectively PUBLIC at runtime (`effectiveSharingModel`, plugin-sharing), + * so R2's hardcoded private baseline refused pairs that are not actually + * external-wider; and draft-state saves, which the lint discipline + * deliberately defers to the draft→active promotion gate (#4463 D1). * * Deliberately write-path only — no zod refine, so grandfathered stored * metadata keeps loading (the ADR-0090 D1 lesson: never change behavior of @@ -63,8 +74,10 @@ export interface ObjectPostureGateContext { } /** - * The gate. Throws 403 `owd_external_wider` / `owd_widening_forbidden` to - * reject the write; returns silently when the body passes. + * The gate. Throws 403 `owd_widening_forbidden` to reject the write; returns + * silently when the body passes. (R2 `owd_external_wider` retired — #8310 + * ruling; the external ≤ internal ordering is refused upstream by the 422 + * runtime lint door, see the header.) */ export function objectPostureGate(ctx: ObjectPostureGateContext): void { const body = ctx.body as Record | null; @@ -75,21 +88,6 @@ export function objectPostureGate(ctx: ObjectPostureGateContext): void { const wInternal = widthOf(internal); const wExternal = widthOf(external); - // R2 — ADR-0090 D11: external ≤ internal. Both sides must be orderable - // canonical scalars; `controlled_by_parent` (or an unset side) is skipped, - // mirroring the lint's SECURITY_EXTERNAL_WIDER rule. An unset internal on - // a custom object resolves to `private` at runtime (ADR-0090 D1), so an - // explicit external wider than that is also caught. - const wInternalEffective = wInternal ?? (internal == null ? OWD_WIDTH['private'] : undefined); - if (wExternal !== undefined && wInternalEffective !== undefined && wExternal > wInternalEffective) { - throw postureError( - 'owd_external_wider', - `object/${ctx.name}: externalSharingModel '${String(external)}' is wider than sharingModel ` - + `'${String(internal ?? 'private (default)')}' — external must be ≤ internal (ADR-0090 D11). ` - + `Tighten externalSharingModel or widen sharingModel in the object definition.`, - ); - } - // R1 — ADR-0086 D1: an environment may only TIGHTEN a packaged object's // posture. Applies only to overlay writes over an artifact-backed object // (the OS_METADATA_WRITABLE escape-hatch path — the default deploy already From 6b6d2dea196464f0db124428c80f8e58116f1788 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:07:57 +0000 Subject: [PATCH 2/6] test: repair objectql fixtures (authored sharingModel) and re-pin rest meta-object-owd-gate to the ruled door order Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- ...ject-primary-designation-roundtrip.test.ts | 6 + ...-object-search-companion-roundtrip.test.ts | 2 + ...meta-object-tenant-index-roundtrip.test.ts | 2 + .../src/metadata-validation-sweep.test.ts | 4 + .../objectql/src/overlay-precedence.test.ts | 2 + .../src/package-disable-enforcement.test.ts | 2 + ...otocol-boot-object-package-binding.test.ts | 2 + .../src/protocol-commit-history.test.ts | 2 + ...otocol-delete-object-registry-heal.test.ts | 2 + packages/objectql/src/protocol-meta.test.ts | 5 +- .../src/protocol-object-overlay-layer.test.ts | 4 + .../src/protocol-publish-rollback.test.ts | 3 +- ...rotocol-writepath-object-ownership.test.ts | 2 + .../rest/src/meta-object-owd-gate.test.ts | 204 ++++++++++++------ 14 files changed, 172 insertions(+), 70 deletions(-) diff --git a/packages/objectql/src/meta-object-primary-designation-roundtrip.test.ts b/packages/objectql/src/meta-object-primary-designation-roundtrip.test.ts index b688b62ded..53406ea673 100644 --- a/packages/objectql/src/meta-object-primary-designation-roundtrip.test.ts +++ b/packages/objectql/src/meta-object-primary-designation-roundtrip.test.ts @@ -82,6 +82,10 @@ interface Row { const AUTHORED = { name: 'crm_lead', label: 'Lead', + // [#8310] The runtime object door requires an authored OWD + // (`security-owd-unset`); this suite is about designation roundtrips, so + // the posture is the recommended default. + sharingModel: 'private', fields: { name: { name: 'name', label: 'Name', type: 'text' }, code_label: { name: 'code_label', label: 'Code label', type: 'text' }, @@ -242,6 +246,8 @@ describe('[#8268] the write path takes back the `nameField` the read added (#432 const untitled = { name: AUTHORED.name, label: 'Lead', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { seats: { name: 'seats', label: 'Seats', type: 'number' } }, }; const host = await seed(untitled); diff --git a/packages/objectql/src/meta-object-search-companion-roundtrip.test.ts b/packages/objectql/src/meta-object-search-companion-roundtrip.test.ts index e4b80b07b3..0f5ea15b89 100644 --- a/packages/objectql/src/meta-object-search-companion-roundtrip.test.ts +++ b/packages/objectql/src/meta-object-search-companion-roundtrip.test.ts @@ -57,6 +57,8 @@ interface Row { const AUTHORED = { name: 'crm_lead', label: 'Lead', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { name: { name: 'name', label: 'Name', type: 'text' } }, }; diff --git a/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts b/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts index 02de5125c3..231757133c 100644 --- a/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts +++ b/packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts @@ -74,6 +74,8 @@ const PLATFORM_TENANT_INDEX = { fields: ['organization_id'] }; const AUTHORED = { name: 'crm_lead', label: 'Lead', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { name: { name: 'name', label: 'Name', type: 'text' }, code_label: { name: 'code_label', label: 'Code label', type: 'text' }, diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index e5cad32324..21218fd968 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -67,6 +67,10 @@ const FIXTURES: Record = { valid: { name: 'sweep_account', label: 'Account', + // [#8310] The runtime object door requires an authored OWD + // (`security-owd-unset` refuses absence), so a "valid" object + // fixture must author its posture. + sharingModel: 'private', fields: { amount: { name: 'amount', label: 'Amount', type: 'number' } }, }, invalid: { label: 'No Name' }, diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index abd6a7cfab..6372d5bab1 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -237,6 +237,8 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { item: { name: 'tenant_widget', label: 'Widget', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { title: { name: 'title', type: 'text', label: 'Title' } }, }, }, diff --git a/packages/objectql/src/package-disable-enforcement.test.ts b/packages/objectql/src/package-disable-enforcement.test.ts index beb735bbef..995c25ed84 100644 --- a/packages/objectql/src/package-disable-enforcement.test.ts +++ b/packages/objectql/src/package-disable-enforcement.test.ts @@ -73,6 +73,8 @@ const OTHER_PKG = 'app.healthy_demo'; const objectBody = (name: string) => ({ name, label: 'Demo', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' }, }, diff --git a/packages/objectql/src/protocol-boot-object-package-binding.test.ts b/packages/objectql/src/protocol-boot-object-package-binding.test.ts index a3cd72597d..b83a1e3e59 100644 --- a/packages/objectql/src/protocol-boot-object-package-binding.test.ts +++ b/packages/objectql/src/protocol-boot-object-package-binding.test.ts @@ -166,6 +166,8 @@ function objectBody(name: string, extra?: Record) { return { name, label: 'Invoice', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' }, amount: { name: 'amount', type: 'number', label: 'Amount' }, diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts index dfdb67236f..230d07bcfb 100644 --- a/packages/objectql/src/protocol-commit-history.test.ts +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -532,6 +532,8 @@ describe('#6215 — revertCommit restores a PACKAGE-BOUND overlay row', () => { const invoiceBody = (name: string, extra?: Record) => ({ name, label: 'Invoice', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' }, amount: { name: 'amount', type: 'number', label: 'Amount' }, diff --git a/packages/objectql/src/protocol-delete-object-registry-heal.test.ts b/packages/objectql/src/protocol-delete-object-registry-heal.test.ts index be8706f119..1273cd8dd3 100644 --- a/packages/objectql/src/protocol-delete-object-registry-heal.test.ts +++ b/packages/objectql/src/protocol-delete-object-registry-heal.test.ts @@ -45,6 +45,8 @@ const APP_PKG = 'app.myapp'; const invoiceBody = (name: string) => ({ name, label: 'Invoice', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' }, amount: { name: 'amount', type: 'number', label: 'Amount' }, diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index a4beaca165..55e4f804de 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -261,7 +261,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { await protocol.saveMetaItem({ type: 'object', name: 'test_obj', - item: { name: 'test_obj', label: 'Test', fields: {} }, + // [#8310] The runtime object door requires an authored OWD. + item: { name: 'test_obj', label: 'Test', sharingModel: 'private', fields: {} }, }); const stored = registry.getItem('object', 'test_obj'); @@ -1830,6 +1831,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { item: { name: 'crm_quote', label: 'Quote', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { name: { type: 'text' }, amount: { type: 'number' } }, } as any, }); diff --git a/packages/objectql/src/protocol-object-overlay-layer.test.ts b/packages/objectql/src/protocol-object-overlay-layer.test.ts index c15bfee983..dadaa9d689 100644 --- a/packages/objectql/src/protocol-object-overlay-layer.test.ts +++ b/packages/objectql/src/protocol-object-overlay-layer.test.ts @@ -70,6 +70,10 @@ const packagedBody = (name: string) => ({ const overlayBody = (name: string) => ({ name, label: 'Invoice (customized)', + // [#8310] The runtime object door requires an authored OWD. `private` + // matches (does not widen) the packaged baseline, so R1 stays silent and + // each case keeps refusing/passing for its ORIGINAL reason. + sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' }, overlay_only: { name: 'overlay_only', type: 'text', label: 'Overlay only' }, diff --git a/packages/objectql/src/protocol-publish-rollback.test.ts b/packages/objectql/src/protocol-publish-rollback.test.ts index 7eb6e51ffb..fd561784d1 100644 --- a/packages/objectql/src/protocol-publish-rollback.test.ts +++ b/packages/objectql/src/protocol-publish-rollback.test.ts @@ -347,7 +347,8 @@ describe('deleteMetaItem — storage teardown (dropStorage)', () => { engine.syncObjectSchema = vi.fn(); engine.dropObjectSchema = vi.fn(); const protocol = new ObjectStackProtocolImplementation(engine); - await protocol.saveMetaItem({ type: 'object', name, item: { name, label: name, fields: { title: { type: 'text' } } } }); + // [#8310] The runtime object door requires an authored OWD. + await protocol.saveMetaItem({ type: 'object', name, item: { name, label: name, sharingModel: 'private', fields: { title: { type: 'text' } } } }); return { engine, rows, protocol }; }; diff --git a/packages/objectql/src/protocol-writepath-object-ownership.test.ts b/packages/objectql/src/protocol-writepath-object-ownership.test.ts index 6e1d6dac53..aad15acc5d 100644 --- a/packages/objectql/src/protocol-writepath-object-ownership.test.ts +++ b/packages/objectql/src/protocol-writepath-object-ownership.test.ts @@ -159,6 +159,8 @@ function objectBody(name: string, extra?: Record): ServiceObjec return { name, label: 'Invoice', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { name: { name: 'name', type: 'text', label: 'Name' }, amount: { name: 'amount', type: 'number', label: 'Amount' }, diff --git a/packages/rest/src/meta-object-owd-gate.test.ts b/packages/rest/src/meta-object-owd-gate.test.ts index 6fea253623..defae78f3a 100644 --- a/packages/rest/src/meta-object-owd-gate.test.ts +++ b/packages/rest/src/meta-object-owd-gate.test.ts @@ -1,50 +1,44 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #7674 — the ADR-0090 D11 object posture gate, driven through the REAL save - * path on the topology it was measured absent from. + * #7674 → #8310 — OWD posture at the runtime object door, driven through the + * REAL save path on the host-config topology, re-pinned to the ruled door + * ORDER. * - * ## Why this file exists, and why the unit suite could not stand in for it + * ## The door order this file pins (#8310 maintainer ruling) * - * `plugin-security/src/object-posture-gate.test.ts` is **18/18 green** and was - * green throughout — it calls `objectPostureGate(ctx)` directly, so it proves - * the verdict function and nothing about whether anything ever calls it. On - * `origin/main` before this change, a grep for `owd_external_wider` across the - * whole repo found exactly two files: the gate source and that unit test. No - * test anywhere reached the gate through `saveMetaItem`, and the #3050 call - * site that dispatches it was wrapped in `if (this.environmentId !== undefined)` - * — a key the CLI's lightweight host-config assembler leaves undefined - * (`serve.ts`'s `config.objects && !hasObjectQL` branch → `new ObjectQLPlugin()` - * with no options; `isHostConfig` → `shouldBootWithLibrary === false` is the - * flagship showcase's own boot shape). So R1 and R2 executed on **no - * self-hosted deployment at all**, and the measured answers to the three PUTs - * below were `200`, `200`, `200`. + * `saveMetaItem` runs `assertRuntimeAuthoringRules` (the shared lint table — + * 422 `INVALID_METADATA`, per-rule `issues`) BEFORE `runAuthoringGate` (the + * ADR-0094-seam plugin gate — 403). Since #8310 declared `object` in + * `validateSecurityPosture`'s `runtimeTypes`: * - * A unit-tested gate with no integration coverage through the real save path is - * exactly how that survived, which is why this file is a peer of the one-line - * predicate change rather than a garnish on it. + * - **The 422 lint door answers FIRST** for every posture defect it judges: + * an unauthored `sharingModel` (`security-owd-unset` — absence is not a + * decision; the ruled strictness), and external > internal + * (`security-external-wider-than-internal`). + * - **The 403 R1 door (`owd_widening_forbidden`, env-tighten-only over a + * packaged declaration) remains** for writes that pass lint — no lint + * rule can judge it, because it needs the packaged declaration. + * - **R2 (`owd_external_wider`) is RETIRED from the plugin gate** as the + * lint door's duplicate (same ruling; see `object-posture-gate.ts`). Its + * former pins here are re-pinned onto the 422 envelope, and the door-order + * case below proves the lint door answers even when R1 would also refuse. + * - The former pin that a write with NO OWD keys at all SAVES (ADR-0094 + * read absence as the D1 `private` default) is OVERTURNED by the ruling: + * absence now refuses (`security-owd-unset`), pinned below. * - * ## The harness is the host-config topology, deliberately - * - * Nothing here is hand-built: a REAL better-sqlite3 `:memory:` engine, a REAL - * `ObjectStackProtocolImplementation` constructed the way the lightweight - * assembler constructs it (**no environment id, no declared channel** — so the - * constructor default `'environment'` is what arms the gate), the REAL - * `registerObjectPostureGate` wiring plugin-security performs at init, and the - * REAL `PUT /api/v1/meta/:type/:name` route a client calls. `environmentId` - * being undefined is asserted in `boot()` rather than assumed: it is the - * premise of the whole file, and a harness that quietly grew one would turn - * every case below into a test of the pre-#7674 code path. + * #7674's original point stands underneath: this file reaches the doors + * through the real `PUT /api/v1/meta/:type/:name` route on the host-config + * topology (no environment id), where a unit-only gate once silently ran on + * no deployment at all. * * ## Rejection cases assert the ENVELOPE (ADR-0112) * - * Every refusal asserts `code` AND `status`. A bare "it failed" assertion would - * be worthless twice over here: this route ANSWERS rather than throws, and the - * unfixed build answers `200`, so a status-only check would at least catch it — - * but a `rejects.toThrow()`-shaped check at the protocol level would not, since - * an object body that reaches persistence on a misconfigured store throws a - * bare driver `Error` whose `code` and `status` are both `undefined`. The pair - * is what separates "refused by the gate" from "failed somewhere downstream". + * Every refusal asserts `code` AND `status` (and for the 422s, the lint rule + * id inside `issues`). This route ANSWERS rather than throws, and an object + * body that reaches persistence on a misconfigured store throws a bare driver + * `Error` whose `code` and `status` are both `undefined` — the pair is what + * separates "refused by the gate" from "failed somewhere downstream". * * ## Scope * @@ -255,23 +249,22 @@ async function boot(opts: { channel?: MetadataAuthoringChannel; envWritableObjec } // --------------------------------------------------------------------------- -// R2 — ADR-0090 D11: external ≤ internal, on all three doors the issue measured +// The 422 lint door — external ≤ internal and authored-OWD-required (#8310) // --------------------------------------------------------------------------- -describe('[#7674] R2 `owd_external_wider` through PUT /api/v1/meta/object/:name', () => { +describe('[#8310] the 422 lint door through PUT /api/v1/meta/object/:name', () => { /** - * The issue's three measured 200s, as one table. They are separate doors - * rather than one: `?mode=draft` takes the draft branch of `saveMetaItem`, - * `?package=` binds the row to a software package, and the bare PUT is the - * active path. #4463 D1 records what happens when only one of two doors - * gates — the draft is the first half of a second minting path — so all - * three are pinned, not just the headline one. + * #7674 pinned these doors on the plugin gate's 403; #8310 re-pins them on + * the 422 lint envelope, because `validateSecurityPosture` now runs for + * `object` writes FIRST. The bare PUT and the package door both take the + * active-state gate; the draft door is pinned separately below (the lint + * discipline gates the draft→active PROMOTION, #4463 D1, not the draft + * save itself). */ it.each([ { door: 'the active path (bare PUT)', query: {} as Record }, - { door: 'the draft path (`?mode=draft`)', query: { mode: 'draft' } }, { door: 'package authoring (`?package=demo_pkg`)', query: { package: 'demo_pkg' } }, - ])('refuses 403 owd_external_wider on $door', async ({ query }) => { + ])('refuses 422 security-external-wider-than-internal on $door', async ({ query }) => { const { put, storedRows } = await boot(); const res = await put('qa_probe', probeObject({ @@ -279,29 +272,96 @@ describe('[#7674] R2 `owd_external_wider` through PUT /api/v1/meta/object/:name' externalSharingModel: 'public_read', }), query); - // ADR-0112 envelope — both halves. `200` is what this answered before. - expect(res._status).toBe(403); - expect(res._json?.code).toBe('owd_external_wider'); - expect(String(res._json?.error)).toContain('externalSharingModel'); + // ADR-0112 envelope — both halves, plus the lint rule id the 422 + // carries in `issues`. `403 owd_external_wider` is what this answered + // before the retirement; `200` is what it answered before #7674. + expect(res._status).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect((res._json?.issues ?? []).map((i: any) => i.rule)) + .toContain('security-external-wider-than-internal'); // The point the status code alone cannot make: the gate is - // PRE-persistence. A 403 answered after the row landed would still be + // PRE-persistence. A 422 answered after the row landed would still be // the defect, and `GET` would still return the violating pair. expect(await storedRows('qa_probe')).toEqual([]); }, 60_000); - it('catches the unset-internal case too — an absent `sharingModel` is `private` (ADR-0090 D1)', async () => { - // The gate resolves an unset internal to `private` rather than skipping - // the comparison, so the most natural authoring mistake — declaring only - // the external side — is refused rather than waved through. + it('an OWD-less publish is refused — 422 security-owd-unset (absence is not a decision)', async () => { + // THE RULED STRICTNESS (#8310, overturning this file's previous "no + // OWD keys at all SAVES" pin): ADR-0094's reading — absence defaults + // to the D1 `private` — governed while no lint rule ran at this door. + // The maintainer ruling flips it: the runtime object door requires an + // AUTHORED posture, and a body with no `sharingModel` is refused + // outright rather than silently defaulted. + const { put, storedRows } = await boot(); + + const res = await put('qa_probe', probeObject()); + + expect(res._status).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect((res._json?.issues ?? []).map((i: any) => i.rule)).toContain('security-owd-unset'); + expect(await storedRows('qa_probe')).toEqual([]); + }, 60_000); + + it('declaring only the external side is refused as the MISSING internal decision (owd-unset)', async () => { + // #7674's unset-internal case, re-pinned. R2 resolved the unset + // internal to `private` and called the defect `owd_external_wider`; + // the ruled door names the actual defect — the internal decision was + // never authored. const { put, storedRows } = await boot(); const res = await put('qa_probe', probeObject({ externalSharingModel: 'public_read_write' })); - expect(res._status).toBe(403); - expect(res._json?.code).toBe('owd_external_wider'); + expect(res._status).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect((res._json?.issues ?? []).map((i: any) => i.rule)).toContain('security-owd-unset'); expect(await storedRows('qa_probe')).toEqual([]); }, 60_000); + + it('the draft door: a dirty draft SAVES (D1), and the draft→active PROMOTION refuses 422', async () => { + // #7674 pinned a 403 on `?mode=draft` because the plugin gate ran on + // drafts too. The lint discipline is deliberately different (#4463 + // D1): drafts are work-in-progress and save ungated; the gate arms on + // the draft→active promotion, so the second minting path stays closed + // without making a half-finished body unsaveable. That discipline now + // governs the object door too. + const { put, protocol } = await boot(); + + const res = await put('qa_probe', probeObject({ + sharingModel: 'private', + externalSharingModel: 'public_read', + }), { mode: 'draft' }); + expect(res._status, `draft save must pass ungated: ${JSON.stringify(res._json)}`).toBe(200); + + const err = await (protocol as any).publishMetaItem({ type: 'object', name: 'qa_probe' }) + .then(() => null, (e: any) => e); + expect(err, 'the promotion is where the lint verdict binds').toBeInstanceOf(Error); + expect(err.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect((err.issues ?? []).map((i: any) => i.rule)) + .toContain('security-external-wider-than-internal'); + }, 60_000); + + it('door ORDER: when lint AND R1 would both refuse, the 422 lint door answers first', async () => { + // An env overlay over the packaged object whose body is BOTH + // external-wider (lint) and posture-widening against the packaged + // baseline (R1: external `public_read` > declared external `private`). + // The ruling fixes the order: the lint table answers first + // (`saveMetaItem` runs it before `runAuthoringGate`), so the author + // sees the 422 vocabulary, never a coin-flip between two doors. + const { put, storedRows } = await boot(); + + const res = await put('qa_packaged_account', packagedOverlay({ + sharingModel: 'private', + externalSharingModel: 'public_read', + })); + + expect(res._status).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect((res._json?.issues ?? []).map((i: any) => i.rule)) + .toContain('security-external-wider-than-internal'); + expect(await storedRows('qa_packaged_account')).toEqual([]); + }, 60_000); }); // --------------------------------------------------------------------------- @@ -334,11 +394,13 @@ describe('[#7674] R1 `owd_widening_forbidden` through PUT /api/v1/meta/object/:n }, 60_000); it('refuses a widened EXTERNAL side against the packaged baseline', async () => { - // Distinct from R2: `public_read` external against `public_read` - // internal is NOT external-wider, so R2 passes the body. Only the - // comparison against the PACKAGED declaration (external `private`) can - // refuse it. A suite that only ever sent an external-wider pair could - // not tell the two rules apart. + // Distinct from the lint door: `public_read` external against + // `public_read` internal is NOT external-wider, so the 422 lint table + // passes the body. Only the comparison against the PACKAGED + // declaration (external `private`) can refuse it — which is exactly + // why R1 SURVIVES the #8310 retirement while R2 did not. A suite that + // only ever sent an external-wider pair could not tell the doors + // apart. const { put, storedRows } = await boot(); const res = await put('qa_packaged_account', packagedOverlay({ @@ -367,7 +429,11 @@ describe('[#7674] what the gate must still let through', () => { { pair: 'private / private', over: { sharingModel: 'private', externalSharingModel: 'private' } }, { pair: 'public_read / private (external TIGHTER)', over: { sharingModel: 'public_read', externalSharingModel: 'private' } }, { pair: 'public_read / public_read (equal, not wider)', over: { sharingModel: 'public_read', externalSharingModel: 'public_read' } }, - { pair: 'no OWD keys at all', over: {} }, + // 'no OWD keys at all' left this table on #8310: absence is now a + // 422 refusal (`security-owd-unset`), pinned in the lint-door suite + // above — the ruling overturned ADR-0094's absence-defaults-to-private + // reading at this door. + { pair: 'internal only, external unset', over: { sharingModel: 'private' } }, ])('a legal pair still saves — $pair', async ({ over }) => { const { put, storedRows } = await boot(); @@ -401,10 +467,12 @@ describe('[#7674] what the gate must still let through', () => { /** * [#6710] The declared carve-out, preserved. A kernel that claims to BE the - * package author is treated as one by this door too — package authoring is - * gated at build time instead (`validateSecurityPosture` is `CLI_ONLY` in - * `AUTHORING_RULES`, and R1's own message prescribes that route: "widen it - * in the package source and publish through the package pipeline"). + * package author is treated as one by BOTH doors — the lint table and the + * plugin gate each skip the `package-author` channel — because package + * authoring is gated at build time instead (`validateSecurityPosture` + * runs on every CLI command, and R1's own message prescribes that route: + * "widen it in the package source and publish through the package + * pipeline"). * * This case is the guard against the worse defect available here: a gate * that starts refusing package authoring is a regression, not a fix. It is From 9418a71066784dc5c8790ea3fe39d4d600d9bf6d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:11:04 +0000 Subject: [PATCH 3/6] test: repair remaining rest fixtures (authored sharingModel) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- packages/rest/src/meta-published-overlay.test.ts | 2 ++ packages/rest/src/rest-unknown-object-heuristic.test.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/meta-published-overlay.test.ts b/packages/rest/src/meta-published-overlay.test.ts index 5bf85bab01..6417a95309 100644 --- a/packages/rest/src/meta-published-overlay.test.ts +++ b/packages/rest/src/meta-published-overlay.test.ts @@ -169,6 +169,8 @@ function makeRes() { const RUNTIME_BODY = { name: 'proj_task', label: 'Project Task', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' }, done: { type: 'boolean', label: 'Done' }, diff --git a/packages/rest/src/rest-unknown-object-heuristic.test.ts b/packages/rest/src/rest-unknown-object-heuristic.test.ts index e6f0a092ca..634a1cbc28 100644 --- a/packages/rest/src/rest-unknown-object-heuristic.test.ts +++ b/packages/rest/src/rest-unknown-object-heuristic.test.ts @@ -130,8 +130,12 @@ async function bootRealProtocol(dbError: string) { return mountRest(protocol as any); } -/** A spec-valid object body, so the PUT reaches persistence rather than a 422. */ -const ACCT = { name: 'acct', label: 'Acct', fields: { name: { type: 'text', label: 'Name' } } }; +/** + * A spec-valid object body, so the PUT reaches persistence rather than a 422. + * [#8310] Its OWD is authored: the runtime object door refuses an unauthored + * `sharingModel` (`security-owd-unset`). + */ +const ACCT = { name: 'acct', label: 'Acct', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } }; let logged: unknown[][] = []; let spy: ReturnType; From 30b29a9ef5f4e485e62394ed12015d85e96b9844 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:15:37 +0000 Subject: [PATCH 4/6] docs: ADR-0094 R2 retirement amendment, authorization.mdx door-order prose, changeset Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .../object-owd-door-authored-required.md | 45 ++++++++++++++++ content/docs/permissions/authorization.mdx | 52 +++++++++++-------- ...0094-sys-permission-set-pure-projection.md | 35 +++++++++++++ 3 files changed, 111 insertions(+), 21 deletions(-) create mode 100644 .changeset/object-owd-door-authored-required.md diff --git a/.changeset/object-owd-door-authored-required.md b/.changeset/object-owd-door-authored-required.md new file mode 100644 index 0000000000..2ca697fce8 --- /dev/null +++ b/.changeset/object-owd-door-authored-required.md @@ -0,0 +1,45 @@ +--- +"@objectstack/lint": minor +"@objectstack/plugin-security": minor +--- + +feat(lint): an authored OWD is required at the runtime object door — `runtimeTypes` gains `object`, completing the #7891 flip; the plugin gate's R2 `owd_external_wider` arm is retired as its duplicate (#8310, maintainer-ruled) + +The security publish linter (`validateSecurityPosture`, ADR-0090 D7) now runs +for runtime-authored **object** publishes, alongside the `seed` / +`permission` / `book` types that crossed earlier in the #7891 rollout. An +active-state object publish — Studio publish, direct REST save, AI builders — +with **no authored `sharingModel`** is refused with `422 INVALID_METADATA` +(`security-owd-unset` in `issues`): absence is not a decision. Previously the +runtime door accepted OWD-less bodies and silently defaulted them to +`private` (ADR-0090 D1) while the CLI refused the same body — the runtime +door was permanently weaker than the build door on exactly the hottest +AI-author write path. + +Door order at `saveMetaItem`, now pinned end-to-end: the **422 lint door +answers first** (all 12 rule ids of the D7 block, external ≤ internal +included), then the ADR-0094-seam plugin gate answers for what passes lint. +Consequences: + +- `objectPostureGate`'s **R2 arm (`403 owd_external_wider`, external ≤ + internal) is retired as a duplicate** of the lint door (maintainer ruling + on #8310; ADR-0094 amendment rides this change). An external-wider pair now + answers `422` / `security-external-wider-than-internal` instead of `403` / + `owd_external_wider`. R2's only non-shadowed refusals were false positives + (system objects, whose unset OWD is effectively PUBLIC at runtime, and + draft saves, which the lint discipline defers to the draft→active + promotion gate per #4463 D1). +- **R1 stays**: an environment overlay may still only TIGHTEN a packaged + object's posture (`403 owd_widening_forbidden`) — no lint rule can judge + the packaged baseline. +- Draft saves are ungated (work-in-progress may be dirty); the draft→active + promotion runs the same 422 gate, so no defective body reaches `active`. +- The `package-author` channel carve-out (#6710) is unchanged on both doors. + +Migration: author `sharingModel` explicitly on every runtime-published object +body (`'private'` is the recommended default; `'public_read'`, +`'public_read_write'`, `'controlled_by_parent'` for master-detail children). +Stored metadata is untouched — the gate judges new writes only, and a clean +write is never blamed for a pre-existing OWD-less object in the environment +(the gate's baseline/candidate differential cancels context findings). +`OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the loud migration hatch. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 771ca0f191..a765ddd1cd 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -336,29 +336,39 @@ Five mechanisms — four CI-time, one runtime — make the security posture a **checked artifact** rather than a belief: - **Security publish linter** (ADR-0090 D7, `validateSecurityPosture` in - `@objectstack/lint`, gating `os compile`): unset OWD on custom objects, - retired OWD aliases, an external dial wider than internal, `'*'` wildcards - carrying View/Modify All outside the platform admin set, high-privilege - `isDefault` (everyone-suggested) sets, the reserved word "role" in - security identifiers, a `controlled_by_parent` object with no relation the - platform can derive access from (ADR-0055: no required `master_detail`, no - `master_detail` at all, and no required `lookup` — so the runtime denies - every read and refuses every write), and the ADR-0091 grant-lifecycle rules - (a seed grant already expired at authoring time; a delegation row missing - its mandatory `reason`) — every error rule mirrors a runtime gate. + `@objectstack/lint`, gating `os compile` — and, since the #7891 rollout + completed with #8310, the **runtime publish door** for `object` / + `permission` / `book` / seed writes, where a gating finding refuses the + save with `422 INVALID_METADATA` and the rule id in `issues`): unset OWD + on custom objects (`security-owd-unset` — an object publish with no + authored `sharingModel` is refused; absence is not a decision, at the CLI + and at the runtime door alike), retired OWD aliases, an external dial + wider than internal (`security-external-wider-than-internal`), `'*'` + wildcards carrying View/Modify All outside the platform admin set, + high-privilege `isDefault` (everyone-suggested) sets, the reserved word + "role" in security identifiers, a `controlled_by_parent` object with no + relation the platform can derive access from (ADR-0055: no required + `master_detail`, no `master_detail` at all, and no required `lookup` — so + the runtime denies every read and refuses every write), and the ADR-0091 + grant-lifecycle rules (a seed grant already expired at authoring time; a + delegation row missing its mandatory `reason`) — every error rule mirrors + a runtime gate. - **Runtime OWD posture gate** (#3050, `objectPostureGate` in `@objectstack/plugin-security`, registered on the metadata protocol's - pre-persistence `registerAuthoringGate` seam): the two OWD rules the CLI - linter can only check at build time are also enforced on every - runtime-authored object body — Studio drafts, REST saves, AI builders. - An environment overlay of a **packaged** object may only *tighten* - `sharingModel` / `externalSharingModel`, never widen them beyond the - packaged declaration (`403 owd_widening_forbidden` — widen it in the - package source and publish instead; this closes the - `OS_METADATA_WRITABLE=object` escape hatch as an unvalidated widening - path, ADR-0086 D1), and `externalSharingModel ≤ sharingModel` (ADR-0090 - D11) is rejected at save time (`403 owd_external_wider`). Write-path - only: stored metadata keeps loading unchanged. + pre-persistence `registerAuthoringGate` seam): the packaged-baseline rule + no lint rule can judge, enforced on every runtime-authored object body — + Studio drafts, REST saves, AI builders. An environment overlay of a + **packaged** object may only *tighten* `sharingModel` / + `externalSharingModel`, never widen them beyond the packaged declaration + (`403 owd_widening_forbidden` — widen it in the package source and + publish instead; this closes the `OS_METADATA_WRITABLE=object` escape + hatch as an unvalidated widening path, ADR-0086 D1). Write-path only: + stored metadata keeps loading unchanged. The gate's former second rule + (`403 owd_external_wider`, external ≤ internal) was **retired as a + duplicate** when the lint block crossed to the runtime door (#8310 + maintainer ruling): the 422 lint door answers first for external-wider + and for unauthored-OWD bodies, and the 403 gate remains for + packaged-baseline widening only. - **Access-matrix snapshot** (ADR-0090 D6, `buildAccessMatrix` / `diffAccessMatrix`): with `access-matrix.json` committed next to the config, `os compile` fails on any capability drift with semantic lines diff --git a/docs/adr/0094-sys-permission-set-pure-projection.md b/docs/adr/0094-sys-permission-set-pure-projection.md index 80133c02b2..0767729f78 100644 --- a/docs/adr/0094-sys-permission-set-pure-projection.md +++ b/docs/adr/0094-sys-permission-set-pure-projection.md @@ -449,3 +449,38 @@ structurally* — is identical; only the per-type **direction** differs. A separate ADR would duplicate the rationale and split the classification from the decision that motivates it. This addendum keeps the rule and its applications in one place. + +## Amendment (2026-08-14): the object posture gate's R2 arm is retired — the runtime lint door owns external ≤ internal + +Maintainer ruling on #8310 (2026-08-13, accepting the escalated +recommendation in full). This amendment records the retirement of one of the +two rules the `object` authoring gate (`objectPostureGate`, +`@objectstack/plugin-security` — registered on this ADR's +`registerAuthoringGate` seam, #3050/#7674) carried: + +- **R2 (`403 owd_external_wider`, external ≤ internal, ADR-0090 D11) is + RETIRED as a duplicate.** The #7891 rollout completed on #8310: + `validateSecurityPosture` (`@objectstack/lint`) now declares `object` in + its `runtimeTypes`, so every active-state object publish is judged by the + D7 lint block BEFORE this seam's gate runs (`saveMetaItem` runs + `assertRuntimeAuthoringRules` first). The lint door refuses the same + defect as `422 INVALID_METADATA` / `security-external-wider-than-internal` + — and refuses an unauthored `sharingModel` outright + (`security-owd-unset`): under the same ruling, **absence is not a + decision** at the runtime object door, where this gate's R2 had silently + resolved it to `private`. R2's only non-shadowed refusals were false + positives: a system object (`isSystem` / `sys_*`) with no authored + `sharingModel` is effectively PUBLIC at runtime + (`effectiveSharingModel`, plugin-sharing), so R2's hardcoded private + baseline refused pairs that are not external-wider at runtime; and + draft-state saves, which the lint discipline deliberately defers to the + draft→active promotion gate (#4463 D1) — nothing enforcement-reads a + draft body. +- **R1 (`403 owd_widening_forbidden`, env-tighten-only over a packaged + declaration, ADR-0086 D1) STAYS.** No lint rule can judge it: it compares + the write against the packaged DECLARATION, a deployment fact only this + seam holds. + +Door order, as pinned in `packages/rest/src/meta-object-owd-gate.test.ts`: +the 422 lint gate answers first; this seam's 403 R1 door answers for writes +that pass lint. From 588c36dacdf0266c6e70905cf585b42ba5164d25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:47:44 +0000 Subject: [PATCH 5/6] test(runtime): repair object fixtures for the authored-OWD door (#8310) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .../src/domains/meta-published-runtime-publish.test.ts | 3 +++ packages/runtime/src/meta-field-overlay-lock.test.ts | 6 ++++++ packages/runtime/src/meta-write-org-scope.test.ts | 2 ++ 3 files changed, 11 insertions(+) diff --git a/packages/runtime/src/domains/meta-published-runtime-publish.test.ts b/packages/runtime/src/domains/meta-published-runtime-publish.test.ts index d2330c5681..087a66b3fa 100644 --- a/packages/runtime/src/domains/meta-published-runtime-publish.test.ts +++ b/packages/runtime/src/domains/meta-published-runtime-publish.test.ts @@ -153,6 +153,9 @@ function makeProtocol(engine: any, metadata: unknown) { const RUNTIME_BODY = { name: 'proj_task', label: 'Project Task', + // [#8310] The runtime object door requires an authored OWD (the + // draft→active promotion runs the 422 lint gate). + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' }, done: { type: 'boolean', label: 'Done' }, diff --git a/packages/runtime/src/meta-field-overlay-lock.test.ts b/packages/runtime/src/meta-field-overlay-lock.test.ts index fd01eac1f1..eef101a0b2 100644 --- a/packages/runtime/src/meta-field-overlay-lock.test.ts +++ b/packages/runtime/src/meta-field-overlay-lock.test.ts @@ -115,6 +115,10 @@ import type { HttpDispatcherResult } from './http-dispatcher.js'; const PACKAGED_OBJECT = { name: 'showcase_task', label: 'Task', + // [#8310] The runtime object door requires an authored OWD — without it + // the 422 lint door answers first and the NOT_OVERRIDABLE control below + // would be refused for the wrong reason. + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title', required: true }, status: { type: 'select', label: 'Status' }, @@ -126,6 +130,8 @@ const PACKAGED_OBJECT = { const RUNTIME_OBJECT = { name: 'runtime_thing', label: 'Runtime Thing', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { note: { type: 'text', label: 'Note' } }, }; diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 820ce253d8..6ac15bd2aa 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -348,6 +348,8 @@ describe('#7018 — the registry decides whether a metadata write carries the se const OBJECT = { name: 'ticket', label: 'Ticket', + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', fields: { subject: { type: 'text', label: 'Subject' } }, }; From de2dbcc4398e098326069d0e0816c0fb39cb79a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:05:07 +0000 Subject: [PATCH 6/6] test(dogfood): author OWD on published object bodies (#8310) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .../test/package-first-authoring.dogfood.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts b/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts index 6460bf526a..08b7851a16 100644 --- a/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts @@ -107,10 +107,12 @@ describe('dogfood: the package is the authoring & delete unit (ADR-0070 D3/D4)', expect(await ownedNames(ql, BASE)).toContain(OBJ); // 3. PUBLISH — promotes the draft to active (creates the physical table). + // [#8310] The publish authors its OWD — the runtime object door refuses + // an unauthored `sharingModel` (`security-owd-unset`). const published = await protocol.saveMetaItem({ type: 'object', name: OBJ, - item: { name: OBJ, label: 'Widget', fields: { name: { type: 'text', label: 'Name' } } }, + item: { name: OBJ, label: 'Widget', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } }, packageId: BASE, mode: 'publish', }); @@ -125,6 +127,8 @@ describe('dogfood: the package is the authoring & delete unit (ADR-0070 D3/D4)', item: { name: OBJ, label: 'Widget (edited)', + // [#8310] The re-publish authors its OWD too. + sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' }, qty: { type: 'number', label: 'Qty' } }, }, packageId: BASE, @@ -215,10 +219,12 @@ describe('dogfood: duplicate a writable base (ADR-0070 D4)', () => { // Two objects in the source base; the ticket carries a lookup to the customer // (an intra-package reference that must be rewritten to the clone's new name). // duplicate() only clones state:'active' rows, so both must be published. + // [#8310] Both publishes author their OWD — the runtime object door + // refuses an unauthored `sharingModel` (`security-owd-unset`). await protocol.saveMetaItem({ type: 'object', name: 'dfdup_customer', - item: { name: 'dfdup_customer', label: 'Customer', fields: { full_name: { type: 'text', label: 'Name' } } }, + item: { name: 'dfdup_customer', label: 'Customer', sharingModel: 'private', fields: { full_name: { type: 'text', label: 'Name' } } }, packageId: SRC, mode: 'publish', }); @@ -228,6 +234,7 @@ describe('dogfood: duplicate a writable base (ADR-0070 D4)', () => { item: { name: 'dfdup_ticket', label: 'Ticket', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' }, customer: { type: 'lookup', label: 'Customer', reference: 'dfdup_customer' },