diff --git a/.changeset/object-write-gate-five-gating-rules.md b/.changeset/object-write-gate-five-gating-rules.md new file mode 100644 index 0000000000..9379ee1f8c --- /dev/null +++ b/.changeset/object-write-gate-five-gating-rules.md @@ -0,0 +1,29 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): the five gating object rules cross the runtime publish gate — `object` writes are now judged by `validateFunctionalCompleteness`, `validateManagedApiMethods`, `lintAutonumberFormats`, `validateRuleCompilability` and `validateRuleSchemaFormats` (#4716) + +An `active`-state `object` save through `saveMetaItem` (Studio's field editor, +REST `/meta` item CRUD, an MCP/AI author) is now refused with the existing 422 +`invalid_metadata` envelope when it carries a defect these five rules judge: +an inert `summary`/`lookup`/`select` shape, a managed-API verb the object's own +affordances refuse, an autonumber format referencing an unknown field, a +`format` regex or `json_schema` schema the runtime's own compilers reject, or a +`json_schema` `format` name ajv would silently drop. All five already gated +`os validate` / `os build` / `os lint`; the runtime door — the only door a +tenant overlay row has — ran none of them. + +Scope is deliberately the five **gating** rules only (the #4716 adjudication): +the six advisory-tier object rules stay off the runtime surface, so a clean +save's response is byte-identical and no new advisory volume reaches Studio's +designer. Draft saves are untouched (D1), stored rows keep being served +(ADR-0087 asymmetry — the gate's differential blames a write only for what it +adds), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to +a loud log for migration windows. + +Boot-path note: the two schema-judging rules load ajv lazily, only when the +judged snapshot actually carries a `json_schema` validation — an ordinary +field edit still loads no compiler, which `runtime-lazy-deps.test.ts` now pins +as a three-tier contract (parsers never; ajv never without a schema; ajv +required, on demand, when one is present). diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 0111c4cf88..d0058b07e6 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -359,14 +359,35 @@ const RUNTIME_HEAVY_SOURCE_PARSE = 'kernel boot path must never load (lazy-deps.test.ts). Studio compiles page source on its own path.'; /** - * The rule judges an OBJECT/field declaration. Object writes are the hottest - * metadata path there is (every Studio field edit) and the blast radius of a - * wrong 422 there is the whole product, so P1 does not gate them — the issue's - * own worked example, and every acceptance criterion on it, is a flow. + * The rule judges an OBJECT/field declaration at `advisory` tier — it can + * never refuse a write (`tier: 'advisory'` means it never emits `error`, and + * `authoring-rule-wiring.test.ts` reads each advisory rule's own source to + * keep that true). + * + * This constant used to hold the whole object-writes group back ("P1 gates + * `flow` first and widens once the gate has real traffic"). #4716 split that + * group by tier and crossed the five GATING object rules (2026-08-18 + * adjudication): the false-positive budget their crossing owed was exempted on + * a measured 0 refusals across 75 real object declarations from two authoring + * lineages — a LOWER BOUND, since every measured population is authored + * config-file metadata — with a post-launch replay of stored `sys_metadata` + * overlay rows as the standing audit of the exemption. + * + * What holds THIS tier back is not refusal risk but ADVISORY VOLUME: measured + * on the platform's own 45 shipped object declarations, widening these six + * rules adds ~8 advisories per object write (vs 0.10 per write on the + * CI-swept examples), and since #4717 advisories render in Studio's designer + * on every field edit. A designer that answers every save with eight warnings + * teaches its authors — human and AI — to ignore the channel, and an ignored + * advisory channel is worse than none because it reads as covered. Crossing + * an advisory rule is therefore a UX/volume decision with its own card + * (suppress by tier? collapse by rule? surface only on publish?), never a + * bare `runtimeTypes` edit — the #4716 adjudication scoped it out by name. */ -const RUNTIME_OBJECT_WRITES_P2 = - 'P2 (#4463): judges an object/field declaration. Object writes are the hottest metadata path in ' + - 'the product, so P1 gates `flow` first and widens once the gate has real traffic behind it.'; +const RUNTIME_OBJECT_ADVISORY_VOLUME = + 'Advisory-tier object rule: it cannot refuse a write, and it is held off the runtime door for ' + + 'advisory VOLUME (~8 findings per object write measured on unswept metadata, rendered in Studio ' + + 'since #4717), not refusal risk. Crossing it is a UX decision with its own card (#4716).'; /** * `ExprIssue` is the one rule finding that carries no rule id of its own — it @@ -441,8 +462,14 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'normalized', commands: ALL, source: 'packages/lint/src/validate-functional-completeness.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + // Runtime publish gate (#4716): the OBJECT write door — the five gating + // object rules cross together under the 2026-08-18 adjudication. The + // false-positive budget the crossing owed was exempted on a measured + // 0 refusals / 75 real object declarations (authored config-file metadata, + // so a lower bound — see RUNTIME_OBJECT_ADVISORY_VOLUME's note); the six + // advisory-tier object rules deliberately do NOT ride. + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['object'], run: (stack) => validateFunctionalCompleteness(stack), }, // [#7521, via cloud#1225] A managed object advertising a generic write verb @@ -466,8 +493,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'normalized', commands: ALL, source: 'packages/lint/src/validate-managed-api-methods.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + // Runtime publish gate (#4716): a managed object advertising a verb its + // own affordances refuse is exactly the contradiction a Studio/MCP author + // can save today — the CLI sweep (#7934) never sees an overlay row. + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['object'], run: (stack) => validateManagedApiMethods(stack), }, // A view container in `views: []` that registers zero views: nothing appears @@ -789,7 +819,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ALL, source: 'packages/lint/src/validate-record-title.ts', surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, run: (stack) => validateRecordTitle(stack), }, // ADR-0085 — `stageField` / `highlightFields` / `Field.group` are pointers @@ -802,7 +832,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ALL, source: 'packages/lint/src/validate-semantic-roles.ts', surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, run: (stack) => validateSemanticRoles(stack), }, // #2578 / #4449 — a form section's field reference that resolves to nothing @@ -1014,7 +1044,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ALL, source: 'packages/lint/src/lint-liveness-properties.ts', surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, run: (stack) => lintLivenessProperties(stack).map((f) => ({ severity: 'warning' as const, @@ -1034,8 +1064,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'parsed', commands: ALL, source: 'packages/lint/src/lint-autonumber-formats.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + // Runtime publish gate (#4716): an autonumber referencing a field the + // object does not carry is broken from the first record; only the error + // arm blocks — the optional-field arm is `warning` and rides the + // advisory channel like every other non-error finding (#4463 P1). + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['object'], run: (stack) => lintAutonumberFormats(stack).map((f) => ({ severity: f.severity, @@ -1078,7 +1112,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ['validate', 'build'], source: 'packages/lint/src/data-model-rules.ts', surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, scopeReason: '`os lint` already reports this rule through `lintDataModel`, which calls it directly ahead of ' + 'R10 in its best-practice sweep — registering it for `lint` as well would report every finding ' + @@ -1103,7 +1137,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ['validate', 'build'], source: 'packages/lint/src/data-model-rules.ts', surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, scopeReason: "`os lint` already reports this rule through `lintDataModel`, which calls it directly as R10 of " + 'its best-practice sweep — registering it for `lint` as well would report every finding twice. ' + @@ -1129,7 +1163,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ commands: ['validate', 'build'], source: 'packages/lint/src/data-model-rules.ts', surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, scopeReason: '`os lint` already reports this rule through `lintDataModel`, which calls it directly alongside ' + 'R10/R11 in its best-practice sweep — registering it for `lint` as well would report every finding ' + @@ -1337,8 +1371,15 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'parsed', commands: ALL, source: 'packages/lint/src/validate-rule-compilability.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + // Runtime publish gate (#4716): the rule loads ajv LAZILY, only when the + // judged snapshot actually carries a `json_schema` validation — so an + // ordinary object write (no `json_schema` anywhere) still loads no + // compiler, which `runtime-lazy-deps.test.ts` pins in both directions. + // The load it does take (~64 ms cold once, ~15 ms warm per publish + // carrying such a rule) is the measured, adjudicated price of refusing a + // validation rule that would otherwise ship compiled-by-nothing. + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['object'], run: (stack) => validateRuleCompilability(stack), }, // #5178 — the residual half of #5029, which registering `ajv-formats` does @@ -1359,8 +1400,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'parsed', commands: ALL, source: 'packages/lint/src/validate-rule-schema-formats.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_WRITES_P2, + // Runtime publish gate (#4716): crosses with its compile sibling above — + // the two judgements over one artifact stay on one side of the wall + // (#7220's family discipline). Same lazy-ajv contract: the registered + // format set is only enumerated once a schema actually names a format. + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['object'], run: (stack) => validateRuleSchemaFormats(stack), }, ]; diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts new file mode 100644 index 0000000000..fbf7cb42b4 --- /dev/null +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4716 — the OBJECT write door, at its narrowed, adjudicated scope. + * + * Object writes are the hottest metadata path in the product: Studio's field + * editor reaches `saveMetaItem` on every publish, and for a tenant it is the + * ONLY door (`os lint` cannot see a `sys_metadata` overlay row). #4463's P1 + * deliberately gated `flow` first; this file pins the P2 crossing — and, + * just as deliberately, its FENCES. + * + * The 2026-08-18 adjudication on #4716 ruled the scope by TIER: + * + * - the five GATING rules carrying the object-writes reason cross together + * (`validateFunctionalCompleteness`, `validateManagedApiMethods`, + * `lintAutonumberFormats`, `validateRuleCompilability`, + * `validateRuleSchemaFormats`). Their false-positive budget was EXEMPTED on + * a measured 0 refusals across 75 real object declarations from two + * authoring lineages — a lower bound, since every measured population is + * authored config-file metadata — with a post-launch replay of stored + * overlay rows as the standing audit; + * - the six ADVISORY-tier object rules do NOT ride. They cannot refuse a + * write at all, and the measured ~8-advisories-per-object-write designer + * noise they would add is a UX decision with its own card, not a + * `runtimeTypes` edit. The fence is pinned below BY NAME so a later + * widening moves this line consciously rather than by drift. + * + * The six refusal cases are the adjudication's own non-vacuity controls — the + * six synthetic broken bodies the measurement round pushed through the gate + * differential to prove the zero was a fact about the corpus and not about the + * harness (issue #4716, comment 5328421898). They are permanent here so the + * evidence the exemption rests on cannot rot silently. + */ +import { describe, expect, it } from 'vitest'; +import { AUTHORING_RULES } from './authoring-rules.js'; +import { + runRuntimeAuthoringRules, + runtimeAuthoringRulesFor, + runtimeGatedTypes, + stackKeyForType, +} from './runtime-gate.js'; + +/** The five gating rules the adjudication crossed, in registry order. */ +const CROSSED = [ + 'validateFunctionalCompleteness', + 'validateManagedApiMethods', + 'lintAutonumberFormats', + 'validateRuleCompilability', + 'validateRuleSchemaFormats', +] as const; + +/** The six advisory rules the adjudication fenced OUT, by name. */ +const FENCED = [ + 'validateRecordTitle', + 'validateSemanticRoles', + 'lintLivenessProperties', + 'lintUnscopedDeclaredIndexes', + 'lintUniqueDeclarations', + 'lintLegacyOrganizationComposites', +] as const; + +/** + * A stored sibling, clean under every object-gated rule, so the differential's + * baseline contributes nothing and every asserted finding is unambiguously the + * write's own. Same-name writes below exercise the replace-not-erase update + * path (`buildRuntimeWriteSnapshots`), not an insert into an empty tenant. + */ +const STORED = [ + { name: 'leave_request', sharingModel: 'private', fields: { owner: { type: 'text' } } }, +]; + +/** + * A clean object body. `sharingModel` is authored on every fixture in this + * file so `validateSecurityPosture` — at this door since #8310, not this + * card's doing — contributes no `security-owd-unset` and each control stays + * surgical about the one rule it exists to fire. + */ +const cleanObject = (over: Record = {}) => ({ + name: 'leave_request', + label: 'Leave Request', + sharingModel: 'private', + fields: { owner: { type: 'text' } }, + ...over, +}); + +const gateObject = (item: unknown, context: object = { objects: STORED }) => + runRuntimeAuthoringRules({ type: 'object', item, context }); + +/** The one error the write ADDED, asserted with its full 422 envelope keys. */ +const expectSingleRefusal = (item: unknown, rule: string) => { + const result = gateObject(item); + const errs = result.errors.map((f) => f.rule); + const f = result.errors.find((e) => e.rule === rule); + expect(f, `expected [${rule}] among added errors, got: ${JSON.stringify(errs)}`).toBeDefined(); + // The 422 envelope's four keys (#4463 D3): the caller turns `errors` into + // `err.issues` verbatim, so a finding without them is a refusal an author + // cannot act on. + expect(f!.severity).toBe('error'); + expect((f!.path ?? '').length).toBeGreaterThan(0); + expect((f!.where ?? '').length).toBeGreaterThan(0); + expect((f!.message ?? '').length).toBeGreaterThan(10); + return result; +}; + +describe('the object write door dispatches at the adjudicated scope (#4716)', () => { + it('dispatches `object` writes to the five crossed rules plus the two already at the door', () => { + expect(runtimeGatedTypes()).toContain('object'); + expect(stackKeyForType('object')).toBe('objects'); + // Exact, in registry order — "clean" and "nothing ran" must stay + // distinguishable, and a rule silently joining or leaving this door is + // precisely the drift this pin exists to catch. + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toEqual([ + 'validateFunctionalCompleteness', + 'validateManagedApiMethods', + 'validatePresetComparands', // #8793 — at this door before #4716 + 'lintAutonumberFormats', + 'validateSecurityPosture', // #8310 — at this door before #4716 + 'validateRuleCompilability', + 'validateRuleSchemaFormats', + ]); + }); + + it('the six advisory-tier object rules do NOT ride — the Q2 fence, by name', () => { + const atDoor = new Set(runtimeAuthoringRulesFor('object').map((r) => r.name)); + for (const name of FENCED) { + expect(atDoor.has(name), `${name} reached the object write door — the #4716 adjudication ` + + `fenced the advisory tier out (the measured ~8-advisories-per-write designer noise). ` + + `Crossing it is a UX/volume decision with its own card, not a runtimeTypes edit.`).toBe(false); + const entry = AUTHORING_RULES.find((r) => r.name === name); + expect(entry, `${name} left AUTHORING_RULES — re-point this fence or retire it`).toBeDefined(); + expect(entry!.tier, `${name} changed tier — this fence pins the ADVISORY six; a severity ` + + `change needs its own PR and re-opens the crossing question for the rule`).toBe('advisory'); + expect((entry!.surfaceReason ?? '').length, `${name} sits off the runtime surface with no ` + + `substantive reason`).toBeGreaterThanOrEqual(40); + } + // And the five crossed rules really are gating tier — the exemption's + // arithmetic (refusal risk lives only in `error`-capable rules) holds + // only while this stays true. + for (const name of CROSSED) { + const entry = AUTHORING_RULES.find((r) => r.name === name)!; + expect(entry.tier, `${name} is no longer gating — the #4716 exemption priced five gating ` + + `rules; a tier change moves it out of that ruling`).toBe('gating'); + expect(entry.runtimeTypes ?? []).toContain('object'); + } + }); + + // ── The six refusal controls — the exemption's non-vacuity evidence ── + // + // Each body is one a tenant could save through Studio/REST/MCP today: + // Zod-green at the per-type parse, broken in a way only these rules judge. + // Before #4716 every one of them published clean at this door. + + it('REFUSES a format rule whose regex does not compile (validateRuleCompilability)', () => { + expectSingleRefusal( + cleanObject({ + validations: [{ name: 'tax_format', type: 'format', field: 'owner', regex: '([' }], + }), + 'validation-rule-regex-uncompilable', + ); + }); + + it('REFUSES a json_schema rule ajv cannot compile (validateRuleCompilability)', () => { + // `required` must be an array; the runtime's `checkJsonSchema` would log + // "uncompilable — skipped" and enforce NOTHING, forever (#4762). + expectSingleRefusal( + cleanObject({ + validations: [ + { name: 'payload_shape', type: 'json_schema', field: 'owner', schema: { required: 'name' } }, + ], + }), + 'validation-rule-json-schema-uncompilable', + ); + }); + + it('REFUSES a json_schema rule naming an unregistered format (validateRuleSchemaFormats)', () => { + // `emial` compiles under `strict: false` — ajv drops the keyword and the + // rule enforces nothing for it (#5178). The record is ACCEPTED, which is + // the silent direction; this is the door that makes it loud. + expectSingleRefusal( + cleanObject({ + validations: [ + { + name: 'contact_shape', + type: 'json_schema', + field: 'owner', + schema: { type: 'object', properties: { e: { type: 'string', format: 'emial' } } }, + }, + ], + }), + 'validation-rule-json-schema-unknown-format', + ); + }); + + it('REFUSES a managed object advertising verbs its affordances close (validateManagedApiMethods)', () => { + // The #7521 shape: `platform` bucket, `userActions` closing every write, + // `enable.apiMethods` advertising `create`/`update` anyway. The registry + // strips the verbs at boot behind a console.warn nobody reads. + expectSingleRefusal( + cleanObject({ + name: 'sys_environment_like', + managedBy: 'platform', + userActions: { create: false, edit: false, delete: false }, + enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update'] }, + }), + 'object/managed-api-method-unaffordable', + ); + }); + + it('REFUSES an autonumber format referencing a field the object does not carry (lintAutonumberFormats)', () => { + expectSingleRefusal( + cleanObject({ + fields: { + owner: { type: 'text' }, + task_no: { type: 'autonumber', autonumberFormat: '{plan_no}{000}' }, + }, + }), + 'autonumber-references-unknown-field', + ); + }); + + it('REFUSES a summary field declaring no summaryOperations (validateFunctionalCompleteness)', () => { + // ADR-0078: Zod-valid and fully inert — the field reads 0 forever while + // authoring reports success (cloud#687's failure, at the door it ships from). + expectSingleRefusal( + cleanObject({ + fields: { owner: { type: 'text' }, total: { type: 'summary' } }, + }), + 'field/summary-without-operations', + ); + }); + + // ── What must NOT change ───────────────────────────────────────────── + + it('publishes a clean object write with every rule having RUN', () => { + const result = gateObject(cleanObject()); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]); + // "clean" and "nothing ran" must stay distinguishable. + expect(result.rulesRun).toEqual([ + 'validateFunctionalCompleteness', + 'validateManagedApiMethods', + 'validatePresetComparands', + 'lintAutonumberFormats', + 'validateSecurityPosture', + 'validateRuleCompilability', + 'validateRuleSchemaFormats', + ]); + }); + + it('does not blame a clean write for a STORED sibling already in violation (#4463 D4)', () => { + // A tenant's existing overlay rows may violate rules that did not exist + // when they were written; the read path keeps serving them and the gate + // blocks NEW writes only. The differential is what makes that structural: + // the broken sibling's findings appear in both passes and cancel. + const brokenStored = { + name: 'legacy_task', + sharingModel: 'private', + fields: { task_no: { type: 'autonumber', autonumberFormat: '{gone_field}{000}' } }, + validations: [{ name: 'bad', type: 'format', field: 'task_no', regex: '([' }], + }; + const result = runRuntimeAuthoringRules({ + type: 'object', + item: cleanObject(), + context: { objects: [...STORED, brokenStored] }, + }); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + }); + + it('warning-tier findings from the crossed rules ride the advisory channel, never block', () => { + // `lintAutonumberFormats`' optional-field arm is `warning`: the referenced + // field exists but is not required at create time. The write publishes; + // the author is told on the response channel (#4717), not with a 422. + const result = gateObject( + cleanObject({ + fields: { + owner: { type: 'text' }, + plan_no: { type: 'text' }, + task_no: { type: 'autonumber', autonumberFormat: '{plan_no}{000}' }, + }, + }), + ); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + const advisory = result.advisories.find((a) => a.rule === 'autonumber-references-optional-field'); + expect(advisory, JSON.stringify(result.advisories)).toBeDefined(); + expect(advisory!.severity).toBe('warning'); + }); +}); diff --git a/packages/lint/src/runtime-lazy-deps.test.ts b/packages/lint/src/runtime-lazy-deps.test.ts index 1ff45c4cfb..c7cfd26359 100644 --- a/packages/lint/src/runtime-lazy-deps.test.ts +++ b/packages/lint/src/runtime-lazy-deps.test.ts @@ -6,40 +6,43 @@ // `typescript` (~9 MB), `sucrase` or `ajv`. That was enough while the only consumer // was the CLI, which may load anything. #4463 gave the package a consumer on // the kernel boot path — `@objectstack/metadata-protocol`, reached by every -// runtime metadata write — and that consumer needs the stronger claim: +// runtime metadata write — and that consumer needs the stronger claim about +// what RUNNING the gate may load. // -// RUNNING the gate, on a real body of a really-gated type, loads neither. -// -// Import-time laziness alone would not have said this. The registry statically +// Import-time laziness alone would not say this. The registry statically // names the react/jsx rules (one table, by design — see `authoring-rules.ts`), -// so the module graph is present; what must never happen is a runtime write -// TRIGGERING one. The rules #4463 wired to `runtime-publish` — flow, approval, -// expression, reference — read structured metadata and parse no authored -// source, so they cannot. This file is what keeps that true when the next rule -// is wired to the surface: widen `runtimeTypes` onto a type whose snapshot -// carries a hook body, a react page or a `json_schema` validation and this goes -// red, which is the moment to stop and think, not a moment to relax the -// assertion. +// so the module graph is present; what matters is what a runtime write +// TRIGGERS. Since #4716 widened the five gating object rules onto the object +// write door — two of which judge `json_schema` validations through ajv — the +// contract this file pins is THREE-TIER, not a flat "loads nothing": // -// ## Why the probe runs a flow AND an object (#4716) +// 1. `typescript` / `sucrase` (the ~9 MB source parsers) load NEVER — not at +// import, not gating any type, with or without any authored artifact. The +// two rules that need them stay CLI-only (`RUNTIME_HEAVY_SOURCE_PARSE`); +// Studio compiles page source on its own path. +// 2. `ajv` / `ajv-formats` load NEVER at import, NEVER gating a flow, and +// NEVER gating an object write whose snapshot carries no `json_schema` +// validation — the hot path: an ordinary Studio field edit still pays for +// no compiler at all. Both rules load ajv lazily, on first contact with +// an actual schema (`validate-rule-compilability.ts`'s loadAjv note). +// 3. `ajv` / `ajv-formats` DO load — and the gate DOES refuse — when an +// object write carries a `json_schema` validation ajv rejects. That is +// the adjudicated price of #4716 (measured: ~64 ms cold once, ~15 ms warm +// per schema-carrying publish), taken deliberately, and the leg below that +// demands the load is what keeps tier 2's clean reads falsifiable. // -// The prose above was, until #4716, wider than the assertion under it: every -// gate-running probe in this file wrote a `type: 'flow'` body and nothing else. -// Measured on the #4716 cost round, and re-measured on this branch: the heavy -// dep that actually threatens the boot path is `ajv` (+ `ajv-formats`), and -// **the written TYPE is what reaches it, not the rule set.** A `json_schema` -// validation is authored on an OBJECT (`objects[].validations[]`), so a flow -// write can never make `validateRuleCompilability` compile anything, while an -// object write hands it a schema on a plate. Widen that CLI-only rule onto -// `object` — precisely what #4716's first bullet proposes — and a flow-only -// probe stays green while the kernel starts paying for a JSON-Schema compiler -// on every Studio field edit. +// ## The tripwire fired before it was re-pinned (#4716) // -// So the object leg below is not a second copy of the flow leg. It is the leg -// that can actually fail, and it lands BEFORE any widening deliberately: a -// guard authored after the event it was meant to catch cannot be shown to work -// (the #8824 tripwire logic). The `ajv loads on demand` test at the bottom is -// what shows this one can — see its own note. +// This file's object leg landed in PR #9295, BEFORE the widening, precisely so +// the guard could be shown to work (the #8824 tripwire logic: a guard authored +// after the event it was meant to catch cannot be). It did: on the #4716 +// branch, the widening alone turned 3 of 6 legs red — spawned CJS, spawned +// ESM, and the in-process object leg — each naming ajv loaded while gating the +// schema-carrying object write. The re-pin you are reading is the "stop and +// think" that red demanded: the parsers stay banned outright (tier 1), the +// schema-free hot path stays compiler-free (tier 2), and the one load the +// adjudication accepted is now REQUIRED rather than forbidden (tier 3), so it +// can neither regress into an eager import nor quietly stop meaning anything. import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; @@ -54,19 +57,22 @@ const runtimeCjs = join(distDir, 'runtime.cjs'); const runtimeEsm = join(distDir, 'runtime.js'); const indexCjs = join(distDir, 'index.cjs'); -// `ajv` joined the list in #4762: the rule-compilability gate needs a real -// JSON-Schema compiler to judge a `json_schema` validation rule, and that rule -// is CLI-only (`surfaceReason: RUNTIME_OBJECT_WRITES_P2`). So the boot path must -// not pay for it — not at import, and not while gating. Should that rule ever be -// widened to `runtime-publish`, this assertion is what says so out loud — which -// it can only do over a body carrying such a rule, hence `GATED_OBJECT` (#4716). +// `ajv` joined the list in #4762, when `validateRuleCompilability` was +// CLI-only and the boot path was to pay for it never. #4716 widened that rule +// (and `validateRuleSchemaFormats`) onto the object write door, so ajv moved +// from tier 1 (never) to tiers 2/3 of the header contract: still never at +// import and never without a `json_schema` validation in the judged snapshot, +// but REQUIRED to load — lazily, on demand — when one is present. // `ajv-formats` joined in #5029 for the same gate and the same trigger: the gate // compiles in the runtime's ajv ENVIRONMENT, and that environment now registers // the formats plugin. It carries ajv in with it, so listing it here is not // belt-and-braces — it is the second door onto the same load. const LAZY_DEPS = ['typescript', 'sucrase', 'ajv', 'ajv-formats']; -/** The two deps only an OBJECT write can reach today — the #4716 leg's subject. */ +/** The two parsers the gate may load under NO circumstance (tier 1). */ +const PARSER_DEPS = ['typescript', 'sucrase']; + +/** The two deps only a schema-carrying OBJECT write may (and must) reach. */ const SCHEMA_DEPS = ['ajv', 'ajv-formats']; const depLoaded = (cache: Record | undefined, dep: string) => @@ -87,28 +93,22 @@ const GATED_FLOW = { const OBJECTS = [{ name: 'leave_request', fields: { owner: { type: 'text' } } }]; /** - * The #4716 worked example: an OBJECT write that carries the exact artifact the - * heavy dep exists to judge — a `json_schema` validation whose `schema` ajv - * refuses to compile (`type` is not a JSON-Schema type, `required` is not an - * array). - * - * Two properties this body must keep, or the leg below stops meaning anything: + * The tier-2 subject: an OBJECT write with NO `json_schema` validation + * anywhere in its snapshot — the ordinary Studio field edit, the hottest + * write in the product. All five #4716 rules run on it (including the two + * ajv-carrying ones, which meet no schema and must therefore load nothing). * - * 1. **It must trip a rule that gates `object` today**, so the dep probe is not - * vacuously true over a gate that judged nothing. The `relatedListFilter` - * compares a datetime against the bare date-range preset `last_30_days` in - * an ORDERING position, which `validatePresetComparands` (gated on - * `dashboard`/`view`/`object`/`page`/`flow`) refuses as - * `filter-preset-comparand`. Everything else here is deliberately clean — - * `sharingModel` is authored so `validateSecurityPosture`, the other rule - * gating `object`, contributes nothing and the expected finding set stays - * exactly the one this fixture is authored to produce. - * 2. **Its `schema` must stay one ajv rejects**, so the positive control at the - * bottom keeps proving the require-cache probe can SEE the load. That test - * fails loudly if this stops being true, rather than letting the negative - * leg quietly degrade into "a body with nothing in it loads nothing". + * It must keep tripping a rule that gates `object`, so the dep probe is not + * vacuously true over a gate that judged nothing: the `relatedListFilter` + * compares a datetime against the bare date-range preset `last_30_days` in + * an ORDERING position, which `validatePresetComparands` refuses as + * `filter-preset-comparand`. Everything else is deliberately clean — + * `sharingModel` is authored so `validateSecurityPosture` contributes + * nothing, and no field/validation shape draws the #4716 rules — so the + * expected finding set stays exactly the one this fixture is authored to + * produce. */ -const GATED_OBJECT = { +const PLAIN_OBJECT = { name: 'leave_request', label: 'Leave Request', sharingModel: 'private', @@ -122,6 +122,22 @@ const GATED_OBJECT = { relatedListFilter: { created_at: { $gte: 'last_30_days' } }, }, }, +}; + +/** + * The tier-3 subject: the same object CARRYING the exact artifact the heavy + * dep exists to judge — a `json_schema` validation whose `schema` ajv refuses + * to compile (`type` is not a JSON-Schema type, `required` is not an array). + * + * Its `schema` must stay one ajv rejects, for both directions of the pin: the + * tier-3 leg demands the gate REFUSE it (`validation-rule-json-schema- + * uncompilable`) while loading ajv, and the positive control at the bottom + * uses the same body to prove the require-cache probe can SEE the load. Both + * fail loudly if this stops being true, rather than letting the tier-2 leg + * quietly degrade into "a body with nothing in it loads nothing". + */ +const SCHEMA_OBJECT = { + ...PLAIN_OBJECT, validations: [ { name: 'payload_shape', type: 'json_schema', schema: { type: 'not_a_real_type', required: 'owner' } }, ], @@ -169,22 +185,49 @@ const childBody = ` if (loaded(dep)) fail(dep + ' was loaded by RUNNING the runtime publish gate'); } - // #4716: the same claim over an OBJECT write carrying a \`json_schema\` - // validation — the only written type that can reach ajv today. + // Tier 2 (#4716): an OBJECT write with no json_schema validation runs all + // five widened rules — including the two ajv-carrying ones — and still + // loads nothing: the ordinary Studio field edit stays compiler-free. const objectResult = mod.runRuntimeAuthoringRules({ type: 'object', - item: ${JSON.stringify(GATED_OBJECT)}, + item: ${JSON.stringify(PLAIN_OBJECT)}, context: { objects: ${JSON.stringify(OBJECT_CONTEXT)} }, }); if (objectResult.rulesRun.length === 0) { fail('no rule gates an object write — the object leg would then assert nothing'); } + if (!objectResult.rulesRun.includes('validateRuleCompilability')) { + fail('validateRuleCompilability is not dispatched for object writes — tier 2 would then be ' + + 'vacuous, a clean read over a gate that never touches the ajv-carrying rules (#4716)'); + } if (!objectResult.errors.some((f) => f.rule === 'filter-preset-comparand')) { fail('the gate produced no finding on the object write — the probe below would be vacuously true'); } for (const dep of ${JSON.stringify(LAZY_DEPS)}) { if (loaded(dep)) { - fail(dep + ' was loaded by RUNNING the runtime publish gate on an OBJECT write carrying a json_schema validation'); + fail(dep + ' was loaded by RUNNING the runtime publish gate on an object write carrying NO json_schema validation'); + } + } + + // Tier 3 (#4716): the SAME write carrying a json_schema validation must be + // REFUSED by name, ajv must have loaded to judge it, and the parsers must + // still be absent. Last, deliberately: after this the cache is dirty. + const schemaResult = mod.runRuntimeAuthoringRules({ + type: 'object', + item: ${JSON.stringify(SCHEMA_OBJECT)}, + context: { objects: ${JSON.stringify(OBJECT_CONTEXT)} }, + }); + if (!schemaResult.errors.some((f) => f.rule === 'validation-rule-json-schema-uncompilable')) { + fail('the gate did not refuse the uncompilable json_schema — either SCHEMA_OBJECT degraded ' + + 'into a schema ajv accepts, or the #4716 widening came back off the object door'); + } + for (const dep of ${JSON.stringify(PARSER_DEPS)}) { + if (loaded(dep)) fail(dep + ' was loaded while gating an object write — no written type may reach a source parser'); + } + for (const dep of ${JSON.stringify(SCHEMA_DEPS)}) { + if (!loaded(dep)) { + fail(dep + ' did NOT load while the gate judged a json_schema validation — the tier-2 clean ' + + 'reads above are then unfalsifiable; fix the probe, do not trust the greens'); } } console.log('OK'); @@ -196,7 +239,7 @@ const COLD_LOAD_TIMEOUT_MS = 30_000; describe('@objectstack/lint/runtime (kernel boot-path contract, #4463)', () => { it.skipIf(!existsSync(runtimeCjs))( - 'built CJS runtime entry loads no heavy dep, at import OR while gating', + 'built CJS runtime entry honours the three-tier dep contract, at import AND while gating', () => { const out = execFileSync( process.execPath, @@ -209,7 +252,7 @@ describe('@objectstack/lint/runtime (kernel boot-path contract, #4463)', () => { ); it.skipIf(!existsSync(runtimeEsm))( - 'built ESM runtime entry loads no heavy dep, at import OR while gating', + 'built ESM runtime entry honours the three-tier dep contract, at import AND while gating', () => { const out = execFileSync( process.execPath, @@ -254,53 +297,63 @@ describe('@objectstack/lint/runtime (kernel boot-path contract, #4463)', () => { } }, COLD_LOAD_TIMEOUT_MS); - // #4716. The leg that can actually fail: `json_schema` validations are - // authored on objects, so this is the only written type whose snapshot can - // hand `validateRuleCompilability` a schema to compile. Runs in-process for - // the same reason the flow leg does, and BEFORE the positive control below - // (which is spawned precisely so it can never poison this cache). - it('gating an object that carries a json_schema validation loads no compiler, and still finds the defect', async () => { + // #4716 tier 2, in-process: an object write with NO json_schema validation + // runs all five widened rules — the two ajv-carrying ones included — and + // loads no compiler. This is the hot path (every ordinary Studio field + // edit), and it runs BEFORE any schema-carrying probe deliberately: the + // tier-3 legs are spawned precisely so they can never poison this cache. + it('gating an object with NO json_schema validation loads no dep, and still finds the defect', async () => { const req = createRequire(import.meta.url); const { runRuntimeAuthoringRules } = await import('./runtime.js'); const result = runRuntimeAuthoringRules({ type: 'object', - item: GATED_OBJECT, + item: PLAIN_OBJECT, context: { objects: OBJECT_CONTEXT }, }); - // Two non-vacuity claims, because an object write can be empty in two ways: - // no rule gating the type at all, or a body no gating rule objects to. + // Three non-vacuity claims, because this leg can go hollow three ways: no + // rule gating the type, a dispatch that lost the ajv-carrying rules (a + // clean read over rules that never ran), or a body no gating rule objects to. expect( result.rulesRun.length, 'no registry rule gates an object write — this leg would then prove nothing about the boot path', ).toBeGreaterThan(0); + expect( + result.rulesRun, + 'the ajv-carrying rule is not dispatched for object writes — the clean read below would then ' + + 'be vacuous (#4716)', + ).toContain('validateRuleCompilability'); expect(result.errors.map((f) => f.rule)).toContain('filter-preset-comparand'); for (const dep of LAZY_DEPS) { expect( depLoaded(req.cache, dep), - `${dep} loaded while gating an OBJECT write carrying a json_schema validation. The metadata ` + - `write path is on the kernel boot path: Studio's designer reaches it on every field edit, and ` + - `it may not start paying for a JSON-Schema compiler. If a rule was just widened onto 'object', ` + - `this is the moment to stop and think (#4716) — not to relax the assertion`, + `${dep} loaded while gating an object write that carries no json_schema validation. The ` + + `metadata write path is on the kernel boot path: Studio's designer reaches it on every ` + + `field edit, and a schema-free save may not pay for a compiler — the #4716 adjudication ` + + `priced the load for schema-CARRYING writes only. Do not relax this; find the eager import`, ).toBe(false); } }, COLD_LOAD_TIMEOUT_MS); - // #4716 — the probe's own non-vacuity, and the reason to trust the leg above. + // #4716 — the probe's own non-vacuity, and the diagnostic that splits the + // two ways tier 3 can fail. // // A require-cache walk that reports "clean" proves nothing until it has been - // shown to report "dirty" for the load it exists to catch. So: hand the SAME - // object body to `validateRuleCompilability` — the CLI-only rule that would - // run at the gate if #4716's first bullet widened it onto `object` — and - // demand that ajv and ajv-formats DO appear in the cache. Measured on this - // branch: 63 ajv modules + 3 ajv-formats modules, cold ~56 ms vs warm ~11 ms. + // shown to report "dirty" for the load it exists to catch. The tier-3 stage + // of the spawned legs above already demands the load through the GATE; this + // control demands it from the RULE directly, bypassing the dispatch table. + // When tier 3 fails "ajv did not load", the two together name the culprit: + // this control green means the probe sees loads fine and the gate stopped + // dispatching the rule; this control red means the probe itself (or the + // fixture's schema) broke, and no clean read in this file can be trusted. + // Measured: 63 ajv modules + 3 ajv-formats modules, cold ~56 ms vs warm ~11 ms. // - // Spawned, never in-process: this test deliberately loads the deps every - // other test here forbids, and the native require cache it writes into is the + // Spawned, never in-process: this test deliberately loads the deps the + // tier-2 legs forbid, and the native require cache it writes into is the // very one the in-process leg reads. it.skipIf(!existsSync(indexCjs) || !existsSync(runtimeCjs))( - 'the same object body DOES load ajv when the compilability rule judges it (probe is not vacuous)', + 'the schema-carrying body DOES load ajv when the compilability rule judges it directly (probe is not vacuous)', () => { const out = execFileSync( process.execPath, @@ -314,15 +367,15 @@ describe('@objectstack/lint/runtime (kernel boot-path contract, #4463)', () => { } const snapshots = rt.buildRuntimeWriteSnapshots({ type: 'object', - item: ${JSON.stringify(GATED_OBJECT)}, + item: ${JSON.stringify(SCHEMA_OBJECT)}, context: { objects: ${JSON.stringify(OBJECT_CONTEXT)} }, }); - if (!snapshots) fail('the gate builds no snapshot for an object write — the leg above is then untested'); + if (!snapshots) fail('the gate builds no snapshot for an object write — the tier legs above are then untested'); const findings = full.validateRuleCompilability(snapshots.candidate); if (!findings.some((f) => f.rule === 'validation-rule-json-schema-uncompilable')) { - fail('GATED_OBJECT no longer carries a schema ajv refuses to compile, so the negative leg ' + - 'above has degraded into "a body with nothing in it loads nothing". Restore a schema ' + - 'ajv rejects rather than deleting this test.'); + fail('SCHEMA_OBJECT no longer carries a schema ajv refuses to compile, so the tier-2 legs ' + + 'have degraded into "a body with nothing in it loads nothing" and tier 3 refuses ' + + 'nothing. Restore a schema ajv rejects rather than deleting this test.'); } for (const dep of ${JSON.stringify(SCHEMA_DEPS)}) { if (!loaded(dep)) { diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 1c2abddd98..83256b758c 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -31,6 +31,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // of this package's (file, verb) pairs sat in the gate's DEBT ledger until // #5619 sank the two predicates into a package both sides already depend on. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +// [#4716] The advisory-tier rule the Q2 fence test proves its body WOULD trip +// — imported from the full barrel deliberately: this is a TEST, not the gate +// (the gate itself may only reach the registry through `@objectstack/lint/runtime`, +// which the wiring guard enforces on the gate's own source). +import { validateSemanticRoles } from '@objectstack/lint'; import { ObjectStackProtocolImplementation } from './protocol.js'; import type { MetadataAuthoringChannel } from './protocol.js'; @@ -321,16 +326,18 @@ describe('runtime authoring gate on saveMetaItem (#4463)', () => { } }); - it('does not gate a metadata type no rule declares (P1 wires `flow` only)', async () => { - // `object` writes are deliberately outside P1 — see the registry's - // RUNTIME_OBJECT_WRITES_P2 reason. A type nobody declared must pass - // through untouched rather than be silently half-checked. - // - // [#8308] The body carries an authored `sharingModel` so this write is - // ALSO clean on the gated side: it succeeds today because nothing runs, - // and keeps succeeding when #8310 declares `object` in `runtimeTypes` - // (at which point this case's "no rule declares" premise ends — #8310 - // owns re-pinning what this test asserts). + it('publishes a clean object write through the fully widened door (#4716)', async () => { + // HISTORY: this case was born as "does not gate a metadata type no + // rule declares (P1 wires `flow` only)". That premise ended twice — + // #8310 put `validateSecurityPosture` on `object` writes, and #4716 + // crossed the five gating object rules — so what it pins now is the + // accept side of the widened door: a body clean under ALL SEVEN + // object-gated rules (authored `sharingModel`, no broken validation / + // autonumber / summary / apiMethods shape) publishes exactly as it + // did when nothing ran. The refusal side lives in the #4716 block + // below. A dedicated ungated-type case is deliberately not minted + // here: `runtime-gate.test.ts` pins `runtimeAuthoringRulesFor` on an + // undeclared type returning [], at the layer that owns dispatch. const { protocol } = makeProtocol(); const result = await protocol.saveMetaItem({ type: 'object', @@ -589,3 +596,152 @@ describe('#6710 — gate activation is keyed on the declared authoring channel', expect(flowRows(host.rows)).toHaveLength(1); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #4716 — the OBJECT write door, end to end through `saveMetaItem`. +// +// The narrowed, adjudicated scope (2026-08-18): the five GATING rules carrying +// the object-writes reason cross onto `object` writes; the six advisory-tier +// object rules do NOT ride. The lint layer pins dispatch and the six refusal +// controls (`runtime-gate.object-writes.test.ts`); this block pins what a +// Studio/REST/MCP author actually experiences at the door — the 422 envelope, +// D1's draft carve-out, the clean-save wire shape, and the Q2 fence. +// ───────────────────────────────────────────────────────────────────────────── + +describe('runtime authoring gate on OBJECT writes (#4716)', () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + const objectRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'object'); + + const saveObject = (protocol: any, item: unknown, extra: Record = {}) => + protocol.saveMetaItem({ type: 'object', name: 'task', item, ...extra }); + + /** + * Zod-green at the per-type parse, broken only where `lintAutonumberFormats` + * judges: the autonumber interpolates a field the object does not carry, so + * the counter is broken from the first record. Before #4716 this exact body + * published clean through Studio. + */ + const brokenAutonumberObject = () => ({ + name: 'task', + label: 'Task', + sharingModel: 'private', + fields: { + owner: { type: 'text', label: 'Owner' }, + task_no: { type: 'autonumber', label: 'Task No', autonumberFormat: '{plan_no}{000}' }, + }, + }); + + /** The same shape with the referenced field declared and required. */ + const cleanTaskObject = () => ({ + name: 'task', + label: 'Task', + sharingModel: 'private', + fields: { + owner: { type: 'text', label: 'Owner' }, + }, + }); + + it('refuses an ACTIVE object publish with a 422 in the structured envelope', async () => { + const { protocol, rows } = makeProtocol(); + + const err = await saveObject(protocol, brokenAutonumberObject()).catch((e: any) => e); + expect(err.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + + const issue = err.issues.find((i: any) => i.rule === 'autonumber-references-unknown-field'); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(issue.severity).toBe('error'); + expect(String(issue.path).length).toBeGreaterThan(0); + expect(issue.message).toContain('plan_no'); + expect(issue.hint.length).toBeGreaterThan(10); + + // Which rules produced the verdict — "clean" and "nothing ran" stay + // distinguishable from the outside. + expect(err.rulesRun).toContain('lintAutonumberFormats'); + + // Nothing landed. A gate that rejects AFTER persisting is a log line. + expect(objectRows(rows)).toEqual([]); + }); + + it('refuses a json_schema validation ajv cannot compile — the lazy-compiler leg, end to end', async () => { + // The runtime's `checkJsonSchema` would log "uncompilable — skipped" + // and enforce NOTHING for every record, forever (#4762). ajv loads + // lazily inside the rule to judge exactly this; the boot-path contract + // around that load is pinned in `runtime-lazy-deps.test.ts`. + const { protocol, rows } = makeProtocol(); + const err = await saveObject(protocol, { + ...cleanTaskObject(), + validations: [ + { + name: 'payload_shape', + type: 'json_schema', + field: 'owner', + message: 'payload must match the declared shape', + schema: { required: 'name' }, + }, + ], + }).catch((e: any) => e); + + expect(err.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + const issue = err.issues.find((i: any) => i.rule === 'validation-rule-json-schema-uncompilable'); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(err.rulesRun).toContain('validateRuleCompilability'); + expect(objectRows(rows)).toEqual([]); + }); + + it('lets the same broken body through as a DRAFT (D1 unchanged for object writes)', async () => { + const { protocol, rows } = makeProtocol(); + const result = await saveObject(protocol, brokenAutonumberObject(), { mode: 'draft' }); + expect(result.success).toBe(true); + const states = objectRows(rows).map((r) => r.state); + expect(states).toContain('draft'); + expect(states, 'a draft save must not mint an active row').not.toContain('active'); + }); + + it('publishes a clean object with no advisories key — the clean save stays byte-identical', async () => { + const { protocol, rows } = makeProtocol(); + const result = await saveObject(protocol, cleanTaskObject()); + expect(result.success).toBe(true); + expect('advisories' in result, `response carried: ${JSON.stringify(result.advisories)}`).toBe(false); + expect(objectRows(rows)).toHaveLength(1); + }); + + it('the six advisory-tier object rules do NOT ride — the Q2 fence, at the wire', async () => { + // A field `group` naming a fieldGroup the object never declares is + // exactly what `validateSemanticRoles` (advisory tier) flags. First + // prove the body WOULD trip it — a fence test over a body no fenced + // rule objects to would pin nothing… + const body = { + ...cleanTaskObject(), + fields: { owner: { type: 'text', label: 'Owner', group: 'main_info' } }, + }; + const wouldFire = validateSemanticRoles({ objects: [body] }); + expect( + wouldFire.some((f: any) => f.rule === 'field-group-undeclared'), + `the fixture stopped tripping validateSemanticRoles (${JSON.stringify(wouldFire)}) — ` + + 'restore a body the fenced tier flags, or this fence test is vacuous', + ).toBe(true); + + // …then that the door neither refuses NOR advises: the adjudication's + // Q2 resolution is that the measured ~8-advisories-per-object-write + // designer noise never materialises at this scope. An `advisories` key + // appearing here means an advisory rule crossed the wall — that is a + // UX/volume decision with its own card, not a drive-by. + const { protocol, rows } = makeProtocol(); + const result = await saveObject(protocol, body); + expect(result.success).toBe(true); + expect('advisories' in result, `advisories leaked: ${JSON.stringify(result.advisories)}`).toBe(false); + expect(objectRows(rows)).toHaveLength(1); + }); +}); diff --git a/packages/runtime/src/meta-field-overlay-lock.test.ts b/packages/runtime/src/meta-field-overlay-lock.test.ts index 3a9e9635f4..846f75e291 100644 --- a/packages/runtime/src/meta-field-overlay-lock.test.ts +++ b/packages/runtime/src/meta-field-overlay-lock.test.ts @@ -117,11 +117,14 @@ const PACKAGED_OBJECT = { 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. + // would be refused for the wrong reason. [#4716] Same discipline for the + // five gating object rules that crossed that door: the select declares + // `options` so `validateFunctionalCompleteness` stays silent and the 403 + // keeps being the sentence under test. sharingModel: 'private', fields: { title: { type: 'text', label: 'Title', required: true }, - status: { type: 'select', label: 'Status' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }] }, }, _packageId: 'com.example.showcase', };