From f24570036e54f0fa3a3561e3f5367de2ebc8fbac Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:57:40 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(lint):=20split=20OWD=5FALIAS=5FFIX=20pr?= =?UTF-8?q?ovenance=20=E2=80=94=20three=20retired=20D4=20aliases=20vs=20on?= =?UTF-8?q?e=20wrong-layer=20fix-it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OWD_ALIAS_FIX` labelled all four of its keys `[ADR-0090 D4] Legacy alias → canonical fix-it mapping`, but D4 names three: "The legacy aliases `read`, `read_write`, `full` are removed from the zod enum". `public` was never an accepted `sharingModel` value in any shipped schema, so it has no retirement behind it and no ADR-0087 conversion or semantic-migration entry could exist for it — the stored population is zero by construction. Split the map into `OWD_RETIRED_ALIAS_FIX` (the three D4 aliases) and `OWD_WRONG_LAYER_FIX` (`public`), keep the union as `OWD_ALIAS_FIX` so every key still earns the same rule id, path and fix-it, and give the finding's message one shared provenance clause so it stops telling authors that `public` is a retired D4 alias. Also correct the five `packages/cli` serve e2e fixtures that declared `sharingModel: 'public'` — a value `ObjectSchema` refuses and `effectiveSharingModel` folds to `private`. They now declare `private`, which is the posture those apps already had. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- ...serve-mcp-capability-collision.e2e.test.ts | 2 +- .../test/serve-mcp-stdio-answers.e2e.test.ts | 2 +- ...ve-node-env-production-default.e2e.test.ts | 2 +- .../test/serve-process-child-env.e2e.test.ts | 2 +- .../serve-stdio-stdout-purity.e2e.test.ts | 2 +- .../lint/src/validate-security-posture.ts | 64 +++++++++++++++++-- 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts b/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts index 7a007fd1e9..73b67a0327 100644 --- a/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts +++ b/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts @@ -122,7 +122,7 @@ export default { objects: [{ name: 'mcpcollision_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], // NOTE: \`mcp\` is deliberately NOT declared. The banner advertises the MCP diff --git a/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts b/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts index df61d29125..b77f5093f0 100644 --- a/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts +++ b/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts @@ -105,7 +105,7 @@ export default { objects: [{ name: 'stdioprobe_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], }; diff --git a/packages/cli/test/serve-node-env-production-default.e2e.test.ts b/packages/cli/test/serve-node-env-production-default.e2e.test.ts index 9350089f47..b74f5d127e 100644 --- a/packages/cli/test/serve-node-env-production-default.e2e.test.ts +++ b/packages/cli/test/serve-node-env-production-default.e2e.test.ts @@ -270,7 +270,7 @@ export default { objects: [{ name: 'nodeenvdefault_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], // Constructed here, by the HOST — not left to os serve's own auto-inject. diff --git a/packages/cli/test/serve-process-child-env.e2e.test.ts b/packages/cli/test/serve-process-child-env.e2e.test.ts index 4578f8d23b..d4ca502140 100644 --- a/packages/cli/test/serve-process-child-env.e2e.test.ts +++ b/packages/cli/test/serve-process-child-env.e2e.test.ts @@ -145,7 +145,7 @@ export default { objects: [{ name: 'childenv_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], }; diff --git a/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts b/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts index da773c2e7c..feced02b1c 100644 --- a/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts +++ b/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts @@ -102,7 +102,7 @@ export default { objects: [{ name: 'stdoutpurity_task', label: 'Task', - sharingModel: 'public', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], }; diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index d616b72f1d..da626d828f 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -79,7 +79,9 @@ * ## Intake — which doors can reach `security-owd-alias` at all (#16109) * * `sharingModel` and `externalSharingModel` are CLOSED enums on `ObjectSchema` - * (ADR-0090 D4 / D11): every value `OWD_ALIAS_FIX` names, and every + * (ADR-0090 D4 / D11): every value `OWD_ALIAS_FIX` names — both the D4 + * aliases a shipped schema once accepted and the wrong-layer spellings it + * never did, two histories declared as two maps below — and every other * non-canonical string, is refused by the schema with `invalid_value`. So on * any door that PARSES before the registry runs, this rule's alias branches * are unreachable by construction — the object never arrives. Measured on @@ -108,7 +110,8 @@ * Read the two alias branches below accordingly: they are NOT a second * opinion on the enum, and they are dead on the parsed doors on purpose. They * exist so the UNPARSED doors — `os lint` first, the docs gate second — name - * the canonical replacement instead of letting a retired spelling ride to + * the canonical replacement instead of letting a retired or wrong-layer + * spelling ride to * `os build`, where the enum's generic `invalid_value` is the only message. A * consumer crediting this rule id as live `error` coverage on a * `defineStack`-authored app is crediting the wrong gate: on that door the @@ -152,13 +155,45 @@ export interface SecurityFinding { type AnyRec = Record; const CANONICAL_OWD = ['private', 'public_read', 'public_read_write', 'controlled_by_parent'] as const; -/** [ADR-0090 D4] Legacy alias → canonical fix-it mapping. */ -const OWD_ALIAS_FIX: Record = { +/** + * [ADR-0090 D4] The legacy `sharingModel` spellings a shipped schema once + * ACCEPTED, and the canonical value each becomes. D4 names exactly these + * THREE — "The legacy aliases `read`, `read_write`, `full` are **removed from + * the zod enum**" — and only these three have a retirement behind them: the + * `owd-legacy-read-aliases` ADR-0087 stored-row conversion for the two `read*` + * spellings, and the `13.owd-full-alias-removed` semantic entry for `full`. + */ +const OWD_RETIRED_ALIAS_FIX: Record = { read: 'public_read', read_write: 'public_read_write', full: 'public_read_write', +}; + +/** + * Wrong-layer / misspelling fix-its: values NO shipped schema ever accepted + * *here*. They are NOT retired aliases, and nothing sits behind them to + * retire — no ADR-0087 conversion, no semantic-migration entry, and none is + * possible, because a conversion rewrites a spelling some shipped schema once + * took and the stored population for these is zero by construction (#16517 + * read the enum's whole lifetime over complete history; `public` appears in no + * version of it). + * + * `public` is kept here rather than deleted because it catches a real + * authoring mistake. THREE neighbouring keys on the same `ObjectSchema` take + * `'public'` legally — `access.default` (`z.enum(['public', 'private'])`, + * ADR-0066) and `publicSharing.allowedAudiences` + * (`z.enum(['public', 'link_only', 'signed_in', 'email'])`) — and off-schema + * so does the sharing runtime's own internal vocabulary, + * `effectiveSharingModel(): 'private' | 'read' | 'public'`. `sharingModel` is + * the one neighbour that refuses it, and it fails CLOSED to `private` with no + * notice on the read path, so this fix-it is the author's only signal. + */ +const OWD_WRONG_LAYER_FIX: Record = { public: 'public_read_write', }; + +/** Every value this rule offers a fix-it for — both provenance groups. */ +const OWD_ALIAS_FIX: Record = { ...OWD_RETIRED_ALIAS_FIX, ...OWD_WRONG_LAYER_FIX }; /** D11 ordering for external ≤ internal (controlled_by_parent excluded). */ const OWD_WIDTH: Record = { private: 0, @@ -182,6 +217,23 @@ function owdOf(obj: AnyRec): unknown { return obj.sharingModel; } +/** + * The provenance half of an alias finding's message. + * + * The two halves of `OWD_ALIAS_FIX` do not share a history, so one sentence + * cannot serve both: calling `public` "a retired alias (ADR-0090 D4)" asserts + * an acceptance that never happened, and sends the reader looking for the + * conversion and the semantic entry that would exist if it had. The fix-it is + * identical either way; only this clause differs. + */ +function owdAliasProvenance(value: string): string { + return OWD_RETIRED_ALIAS_FIX[value] + ? `is a retired alias (ADR-0090 D4)` + : `is not an OWD value and never was — ADR-0090 D4 retired 'read', 'read_write' and 'full', ` + + `not this one. '${value}' is legal on the neighbouring 'access.default' / ` + + `'publicSharing.allowedAudiences' keys, not on this one`; +} + /** * A platform / system object: one the tenant did not author. * @@ -394,7 +446,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } where: `object "${objName}"`, path: `${objPath}.sharingModel`, message: - `sharingModel '${owd}' is a retired alias (ADR-0090 D4). The runtime fails CLOSED ` + + `sharingModel '${owd}' ${owdAliasProvenance(owd)}. The runtime fails CLOSED ` + `to 'private' on unknown values, so this object is NOT ${owd === 'read' ? 'readable' : 'writable'} org-wide.`, hint: `Replace with the canonical value: sharingModel: '${OWD_ALIAS_FIX[owd]}'.`, }); @@ -511,7 +563,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } rule: SECURITY_OWD_ALIAS, where: `object "${objName}"`, path: `${objPath}.externalSharingModel`, - message: `externalSharingModel '${external}' is a retired alias (ADR-0090 D4).`, + message: `externalSharingModel '${external}' ${owdAliasProvenance(external)}.`, hint: `Replace with the canonical value: externalSharingModel: '${OWD_ALIAS_FIX[external]}'.`, }); } else if ( From 737ffdaa0bf826bd515c0b309581229e2a7e07e2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:16:36 +0000 Subject: [PATCH 2/3] fix(lint): keep the receiver-coverage guard armed, and add the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new provenance clause named the sibling keys dotted, and this rule's receiver-coverage meta-test scans the module's code text for `receiver.key` reads without being able to tell one inside a message string from a real read — so `access.default` / `publicSharing.allowedAudiences` presented as undeclared reads off receivers that do not exist. Name them undotted instead and record why, rather than excusing them in the guard's PLUMBING set, which would have disarmed a real check for a spelling choice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .changeset/owd-alias-fix-provenance-split.md | 13 +++++++++++++ packages/lint/src/validate-security-posture.ts | 10 ++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .changeset/owd-alias-fix-provenance-split.md diff --git a/.changeset/owd-alias-fix-provenance-split.md b/.changeset/owd-alias-fix-provenance-split.md new file mode 100644 index 0000000000..0608f31b76 --- /dev/null +++ b/.changeset/owd-alias-fix-provenance-split.md @@ -0,0 +1,13 @@ +--- +"@objectstack/lint": patch +--- + +`security-owd-alias` no longer tells authors that `sharingModel: 'public'` is a retired ADR-0090 D4 alias. It never was one — no shipped schema ever accepted it — and the map that said so is now split along the two histories it was conflating. + +`OWD_ALIAS_FIX` carried four keys under one comment, `[ADR-0090 D4] Legacy alias → canonical fix-it mapping`, while D4 names three: "The legacy aliases `read`, `read_write`, `full` are **removed from the zod enum**". Those three have a retirement behind them — the `owd-legacy-read-aliases` ADR-0087 stored-row conversion for the two `read*` spellings, the `13.owd-full-alias-removed` semantic entry for `full`. `public` has neither, and correctly so: a conversion rewrites a spelling some shipped schema once took, and this one never was taken, so its stored population is zero by construction. The missing conversion was the mislabel's shadow, not a gap. + +- **Two maps, one union.** `OWD_RETIRED_ALIAS_FIX` holds the three D4 aliases; `OWD_WRONG_LAYER_FIX` holds `public`; `OWD_ALIAS_FIX` stays as their union, so every key still earns the same rule id, the same path and the same fix-it. No accept set moves and no value starts or stops being reported. +- **The `public` fix-it is KEPT.** It catches a real authoring mistake: three neighbouring keys on the same `ObjectSchema` take `'public'` legally — `access.default` (`z.enum(['public', 'private'])`, ADR-0066) and `publicSharing.allowedAudiences` (`z.enum(['public', 'link_only', 'signed_in', 'email'])`) — and off-schema so does the sharing runtime's own internal vocabulary, `effectiveSharingModel(): 'private' | 'read' | 'public'`. `sharingModel` is the one neighbour that refuses it, and it fails CLOSED to `private` with no notice on the read path, so this fix-it is the author's only signal. +- **The message says which group it is in.** One shared clause, used by both the `sharingModel` and the `externalSharingModel` branch. A retired alias still reads `is a retired alias (ADR-0090 D4)`; `public` now reads that it is not an OWD value and never was, and names the neighbouring keys that do take it. The fix-it text, severity, rule id and path are byte-identical either way. + +Why the wording mattered enough to change: a diagnostic that credits `public` to D4 sends its reader looking for the conversion and the semantic entry that would exist if the acceptance had happened, and finding them absent reads as a data-fidelity defect in the conversion registry. It is not one. diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index da626d828f..5f4da8b0ca 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -225,13 +225,19 @@ function owdOf(obj: AnyRec): unknown { * an acceptance that never happened, and sends the reader looking for the * conversion and the semantic entry that would exist if it had. The fix-it is * identical either way; only this clause differs. + * + * The sibling keys are named undotted on purpose: the receiver-coverage + * meta-test in this rule's test file scans the module's CODE text for + * `receiver.key` reads and cannot tell one inside a message string from a real + * read, so a dotted spelling here would present as an undeclared read off a + * receiver that does not exist. */ function owdAliasProvenance(value: string): string { return OWD_RETIRED_ALIAS_FIX[value] ? `is a retired alias (ADR-0090 D4)` : `is not an OWD value and never was — ADR-0090 D4 retired 'read', 'read_write' and 'full', ` + - `not this one. '${value}' is legal on the neighbouring 'access.default' / ` + - `'publicSharing.allowedAudiences' keys, not on this one`; + `not this. '${value}' is legal on the neighbouring keys 'access' (its 'default') and ` + + `'publicSharing' (its 'allowedAudiences'), just not on this one`; } /** From 9476b40662db35085be43bd8e56493dbff518600 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:49:15 +0000 Subject: [PATCH 3/3] style(lint): reflow the intake docblock paragraph Comment rewrap only, no code change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- packages/lint/src/validate-security-posture.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 5f4da8b0ca..00069e9f36 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -111,8 +111,8 @@ * opinion on the enum, and they are dead on the parsed doors on purpose. They * exist so the UNPARSED doors — `os lint` first, the docs gate second — name * the canonical replacement instead of letting a retired or wrong-layer - * spelling ride to - * `os build`, where the enum's generic `invalid_value` is the only message. A + * spelling ride to `os build`, where the enum's generic `invalid_value` is the + * only message. A * consumer crediting this rule id as live `error` coverage on a * `defineStack`-authored app is crediting the wrong gate: on that door the * credit belongs to the schema's closed enum.