Skip to content

Commit 1408ae3

Browse files
os-elonclaude
andauthored
feat(lint): the five gating object rules cross the runtime publish gate (#4716) (#9825)
* feat(lint): the five gating object rules cross the runtime publish gate (#4716) The 2026-08-18 adjudication's narrowed scope: validateFunctionalCompleteness, validateManagedApiMethods, lintAutonumberFormats, validateRuleCompilability and validateRuleSchemaFormats gain runtimeTypes: ['object']; the six advisory-tier object rules stay behind (advisory-volume decision, its own card). The false-positive budget was exempted on 0 refusals / 75 real object declarations (authored config-file metadata — a lower bound), with a post-launch overlay-row replay as the standing audit. runtime-lazy-deps.test.ts re-pins the boot-path contract as three tiers after its object leg (PR #9295) fired red on this widening as designed: parsers never load; ajv never loads at import or on schema-free writes; ajv is REQUIRED to load, lazily, when an object write carries a json_schema validation — and the gate must refuse the uncompilable one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qYPmkKEsfbWY1yVg83p8F * test(metadata-protocol): object-write door pins — draft mode key and Zod-required message on the json_schema fixture (#4716) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qYPmkKEsfbWY1yVg83p8F * test(runtime): overlay-lock object control declares select options so the 403 stays the sentence under test (#4716) The five gating object rules now answer at the 422 lint door, which speaks before the overlay lock (the established #8310 ordering, recorded in this fixture's own comment). The control body carried an optionless select — incidental, and now refused by validateFunctionalCompleteness as field/choice-without-options — so the NOT_OVERRIDABLE control was refused for the wrong reason. Authoring the options restores the fixture to what it pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 152bff8 commit 1408ae3

6 files changed

Lines changed: 697 additions & 124 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
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)
6+
7+
An `active`-state `object` save through `saveMetaItem` (Studio's field editor,
8+
REST `/meta` item CRUD, an MCP/AI author) is now refused with the existing 422
9+
`invalid_metadata` envelope when it carries a defect these five rules judge:
10+
an inert `summary`/`lookup`/`select` shape, a managed-API verb the object's own
11+
affordances refuse, an autonumber format referencing an unknown field, a
12+
`format` regex or `json_schema` schema the runtime's own compilers reject, or a
13+
`json_schema` `format` name ajv would silently drop. All five already gated
14+
`os validate` / `os build` / `os lint`; the runtime door — the only door a
15+
tenant overlay row has — ran none of them.
16+
17+
Scope is deliberately the five **gating** rules only (the #4716 adjudication):
18+
the six advisory-tier object rules stay off the runtime surface, so a clean
19+
save's response is byte-identical and no new advisory volume reaches Studio's
20+
designer. Draft saves are untouched (D1), stored rows keep being served
21+
(ADR-0087 asymmetry — the gate's differential blames a write only for what it
22+
adds), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to
23+
a loud log for migration windows.
24+
25+
Boot-path note: the two schema-judging rules load ajv lazily, only when the
26+
judged snapshot actually carries a `json_schema` validation — an ordinary
27+
field edit still loads no compiler, which `runtime-lazy-deps.test.ts` now pins
28+
as a three-tier contract (parsers never; ajv never without a schema; ajv
29+
required, on demand, when one is present).

packages/lint/src/authoring-rules.ts

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -359,14 +359,35 @@ const RUNTIME_HEAVY_SOURCE_PARSE =
359359
'kernel boot path must never load (lazy-deps.test.ts). Studio compiles page source on its own path.';
360360

361361
/**
362-
* The rule judges an OBJECT/field declaration. Object writes are the hottest
363-
* metadata path there is (every Studio field edit) and the blast radius of a
364-
* wrong 422 there is the whole product, so P1 does not gate them — the issue's
365-
* own worked example, and every acceptance criterion on it, is a flow.
362+
* The rule judges an OBJECT/field declaration at `advisory` tier — it can
363+
* never refuse a write (`tier: 'advisory'` means it never emits `error`, and
364+
* `authoring-rule-wiring.test.ts` reads each advisory rule's own source to
365+
* keep that true).
366+
*
367+
* This constant used to hold the whole object-writes group back ("P1 gates
368+
* `flow` first and widens once the gate has real traffic"). #4716 split that
369+
* group by tier and crossed the five GATING object rules (2026-08-18
370+
* adjudication): the false-positive budget their crossing owed was exempted on
371+
* a measured 0 refusals across 75 real object declarations from two authoring
372+
* lineages — a LOWER BOUND, since every measured population is authored
373+
* config-file metadata — with a post-launch replay of stored `sys_metadata`
374+
* overlay rows as the standing audit of the exemption.
375+
*
376+
* What holds THIS tier back is not refusal risk but ADVISORY VOLUME: measured
377+
* on the platform's own 45 shipped object declarations, widening these six
378+
* rules adds ~8 advisories per object write (vs 0.10 per write on the
379+
* CI-swept examples), and since #4717 advisories render in Studio's designer
380+
* on every field edit. A designer that answers every save with eight warnings
381+
* teaches its authors — human and AI — to ignore the channel, and an ignored
382+
* advisory channel is worse than none because it reads as covered. Crossing
383+
* an advisory rule is therefore a UX/volume decision with its own card
384+
* (suppress by tier? collapse by rule? surface only on publish?), never a
385+
* bare `runtimeTypes` edit — the #4716 adjudication scoped it out by name.
366386
*/
367-
const RUNTIME_OBJECT_WRITES_P2 =
368-
'P2 (#4463): judges an object/field declaration. Object writes are the hottest metadata path in ' +
369-
'the product, so P1 gates `flow` first and widens once the gate has real traffic behind it.';
387+
const RUNTIME_OBJECT_ADVISORY_VOLUME =
388+
'Advisory-tier object rule: it cannot refuse a write, and it is held off the runtime door for ' +
389+
'advisory VOLUME (~8 findings per object write measured on unswept metadata, rendered in Studio ' +
390+
'since #4717), not refusal risk. Crossing it is a UX decision with its own card (#4716).';
370391

371392
/**
372393
* `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[] = [
441462
input: 'normalized',
442463
commands: ALL,
443464
source: 'packages/lint/src/validate-functional-completeness.ts',
444-
surfaces: CLI_ONLY,
445-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
465+
// Runtime publish gate (#4716): the OBJECT write door — the five gating
466+
// object rules cross together under the 2026-08-18 adjudication. The
467+
// false-positive budget the crossing owed was exempted on a measured
468+
// 0 refusals / 75 real object declarations (authored config-file metadata,
469+
// so a lower bound — see RUNTIME_OBJECT_ADVISORY_VOLUME's note); the six
470+
// advisory-tier object rules deliberately do NOT ride.
471+
surfaces: CLI_AND_RUNTIME,
472+
runtimeTypes: ['object'],
446473
run: (stack) => validateFunctionalCompleteness(stack),
447474
},
448475
// [#7521, via cloud#1225] A managed object advertising a generic write verb
@@ -466,8 +493,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
466493
input: 'normalized',
467494
commands: ALL,
468495
source: 'packages/lint/src/validate-managed-api-methods.ts',
469-
surfaces: CLI_ONLY,
470-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
496+
// Runtime publish gate (#4716): a managed object advertising a verb its
497+
// own affordances refuse is exactly the contradiction a Studio/MCP author
498+
// can save today — the CLI sweep (#7934) never sees an overlay row.
499+
surfaces: CLI_AND_RUNTIME,
500+
runtimeTypes: ['object'],
471501
run: (stack) => validateManagedApiMethods(stack),
472502
},
473503
// A view container in `views: []` that registers zero views: nothing appears
@@ -789,7 +819,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
789819
commands: ALL,
790820
source: 'packages/lint/src/validate-record-title.ts',
791821
surfaces: CLI_ONLY,
792-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
822+
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
793823
run: (stack) => validateRecordTitle(stack),
794824
},
795825
// ADR-0085 — `stageField` / `highlightFields` / `Field.group` are pointers
@@ -802,7 +832,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
802832
commands: ALL,
803833
source: 'packages/lint/src/validate-semantic-roles.ts',
804834
surfaces: CLI_ONLY,
805-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
835+
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
806836
run: (stack) => validateSemanticRoles(stack),
807837
},
808838
// #2578 / #4449 — a form section's field reference that resolves to nothing
@@ -1014,7 +1044,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
10141044
commands: ALL,
10151045
source: 'packages/lint/src/lint-liveness-properties.ts',
10161046
surfaces: CLI_ONLY,
1017-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
1047+
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
10181048
run: (stack) =>
10191049
lintLivenessProperties(stack).map((f) => ({
10201050
severity: 'warning' as const,
@@ -1034,8 +1064,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
10341064
input: 'parsed',
10351065
commands: ALL,
10361066
source: 'packages/lint/src/lint-autonumber-formats.ts',
1037-
surfaces: CLI_ONLY,
1038-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
1067+
// Runtime publish gate (#4716): an autonumber referencing a field the
1068+
// object does not carry is broken from the first record; only the error
1069+
// arm blocks — the optional-field arm is `warning` and rides the
1070+
// advisory channel like every other non-error finding (#4463 P1).
1071+
surfaces: CLI_AND_RUNTIME,
1072+
runtimeTypes: ['object'],
10391073
run: (stack) =>
10401074
lintAutonumberFormats(stack).map((f) => ({
10411075
severity: f.severity,
@@ -1078,7 +1112,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
10781112
commands: ['validate', 'build'],
10791113
source: 'packages/lint/src/data-model-rules.ts',
10801114
surfaces: CLI_ONLY,
1081-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
1115+
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
10821116
scopeReason:
10831117
'`os lint` already reports this rule through `lintDataModel`, which calls it directly ahead of ' +
10841118
'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[] = [
11031137
commands: ['validate', 'build'],
11041138
source: 'packages/lint/src/data-model-rules.ts',
11051139
surfaces: CLI_ONLY,
1106-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
1140+
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
11071141
scopeReason:
11081142
"`os lint` already reports this rule through `lintDataModel`, which calls it directly as R10 of " +
11091143
'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[] = [
11291163
commands: ['validate', 'build'],
11301164
source: 'packages/lint/src/data-model-rules.ts',
11311165
surfaces: CLI_ONLY,
1132-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
1166+
surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME,
11331167
scopeReason:
11341168
'`os lint` already reports this rule through `lintDataModel`, which calls it directly alongside ' +
11351169
'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[] = [
13371371
input: 'parsed',
13381372
commands: ALL,
13391373
source: 'packages/lint/src/validate-rule-compilability.ts',
1340-
surfaces: CLI_ONLY,
1341-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
1374+
// Runtime publish gate (#4716): the rule loads ajv LAZILY, only when the
1375+
// judged snapshot actually carries a `json_schema` validation — so an
1376+
// ordinary object write (no `json_schema` anywhere) still loads no
1377+
// compiler, which `runtime-lazy-deps.test.ts` pins in both directions.
1378+
// The load it does take (~64 ms cold once, ~15 ms warm per publish
1379+
// carrying such a rule) is the measured, adjudicated price of refusing a
1380+
// validation rule that would otherwise ship compiled-by-nothing.
1381+
surfaces: CLI_AND_RUNTIME,
1382+
runtimeTypes: ['object'],
13421383
run: (stack) => validateRuleCompilability(stack),
13431384
},
13441385
// #5178 — the residual half of #5029, which registering `ajv-formats` does
@@ -1359,8 +1400,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
13591400
input: 'parsed',
13601401
commands: ALL,
13611402
source: 'packages/lint/src/validate-rule-schema-formats.ts',
1362-
surfaces: CLI_ONLY,
1363-
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
1403+
// Runtime publish gate (#4716): crosses with its compile sibling above —
1404+
// the two judgements over one artifact stay on one side of the wall
1405+
// (#7220's family discipline). Same lazy-ajv contract: the registered
1406+
// format set is only enumerated once a schema actually names a format.
1407+
surfaces: CLI_AND_RUNTIME,
1408+
runtimeTypes: ['object'],
13641409
run: (stack) => validateRuleSchemaFormats(stack),
13651410
},
13661411
];

0 commit comments

Comments
 (0)