Skip to content

Commit def0d3e

Browse files
Jack Qclaude
andauthored
feat(lint): key collection-resident publish-gate finding paths by name, not the private snapshot index (#10296)
* feat(lint): name-key collection-resident finding paths at the runtime gate The 422/advisory wire path for object/permission/book writes now keys the top-level collection entry by NAME (objects.acme_invoice.sharingModel) instead of the gate's private per-write snapshot index (objects[417].~), which no caller can resolve. Rules keep emitting positional paths; the rewrite happens once, after the differential, in runtime-gate.ts. Fixes #10064 (maintainer ruling 2026-08-20: Option A) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD * feat(bench): default the publish-gate differential to the semantic tuple; changeset + regenerated docs With the wire path name-keyed (#10064), the raw (rule, where, path, message) tuple IS the semantic tuple, so the bench's differential-verdict line reads it by default; an index-insensitive canary prints only when a positional-fallback path moved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f8c285c commit def0d3e

9 files changed

Lines changed: 235 additions & 29 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
Runtime publish-gate findings for collection-resident write types (`object` /
7+
`permission` / `book`) now key the top-level collection entry in
8+
`issues[].path` / `advisories[].path` by NAME —
9+
`objects.acme_invoice.sharingModel` — instead of by the gate's private
10+
per-write snapshot index (`objects[417].sharingModel`), which no caller could
11+
resolve: that index numbered an in-memory array a Studio / MCP / REST receiver
12+
has never seen. Single-member write types keep their trivially-stable
13+
positional form (`flows[0].nodes[1]…`), and nested positions inside one named
14+
item (`objects.acme_invoice.indexes[1]`) stay positional — they index the
15+
author's own document. An entry with no splice-safe name falls back to the
16+
positional spelling. The accepted metadata set is unchanged; only the spelling
17+
of the emitted finding `path` changes, and `RuntimeAuthoringIssueSchema.path`'s
18+
description now states the convention. CLI (`os validate` / `os lint`) output
19+
is unchanged — there the index resolves against the author's own config file.

content/docs/references/api/protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1399,7 +1399,7 @@ List packages response
13991399
| Property | Type | Required | Description |
14001400
| :--- | :--- | :--- | :--- |
14011401
| **rule** | `string` || Stable diagnostic rule id (`flow-multi-write-unfiltered`, `approval-expression-invalid`, …). Machine-readable and stable across releases — the key a renderer groups or suppresses by. |
1402-
| **path** | `string` || Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. |
1402+
| **path** | `string` || Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. For the collection-resident write types (`object` / `permission` / `book`) the TOP-LEVEL collection entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by an array index — the gate evaluates against a private per-write snapshot whose indexes no caller can resolve (#10064). Every other write type is the sole member of its own collection, so its `[0]` is trivially stable and stays positional (`flows[0]...`), as do nested positions inside one named item (`objects.acme_invoice.indexes[1]`), which index the author's own document. |
14031403
| **where** | `string` || Human-readable location — `flow "leave_approval" · node "approve"`. Prose for a person; use `path` for anything mechanical. |
14041404
| **message** | `string` || What is wrong, in the rule author's own words. |
14051405
| **hint** | `string` || How to fix it. |

packages/lint/src/authoring-rules.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,13 @@ export interface AuthoringFinding {
183183
rule: string;
184184
/** Human-readable location, e.g. `object "leave_request"`. */
185185
where: string;
186-
/** Config path, e.g. `objects[3].sharingModel`. */
186+
/**
187+
* Config path, e.g. `objects[3].sharingModel`. Positional as RULES emit it;
188+
* on the runtime gate's wire surface the top-level collection index of a
189+
* collection-resident finding is rewritten to the entry's NAME
190+
* (`objects.acme_invoice.sharingModel`) — see `nameKeyFindingPath` in
191+
* `runtime-gate.ts` (#10064).
192+
*/
187193
path: string;
188194
/** What is wrong. */
189195
message: string;

packages/lint/src/data-model-rules.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,20 @@ export interface LintIssue {
5353
* `f.where ?? f.path` at the adapter) is the same discipline — a consumer-side
5454
* fallback would let the next rule ship the positional spelling again, silently.
5555
*
56-
* `path` is unchanged and stays positional: it is the slot that is SUPPOSED to
57-
* be a config path, `os validate` prints it after `at`, and the runtime gate's
58-
* `fingerprint` reads `where` and `path` together (making `where` more specific
59-
* cannot merge two findings that were distinct).
56+
* `path` is unchanged and stays positional AT THE RULE LEVEL: it is the slot
57+
* that is SUPPOSED to be a config path, `os validate` prints it after `at`
58+
* (where the index resolves against the author's own config file), and the
59+
* runtime gate's `fingerprint` reads `where` and `path` together (making
60+
* `where` more specific cannot merge two findings that were distinct).
61+
*
62+
* [#10064] On the runtime gate's WIRE surface (`RuntimeAuthoringIssue.path`,
63+
* the 422 `issues[]` / 2xx `advisories`), the top-level collection index of a
64+
* collection-resident finding is rewritten to the entry's NAME
65+
* (`objects[417].sharingModel` → `objects.acme_invoice.sharingModel`) after
66+
* the differential — a runtime caller has no array to resolve `417` against;
67+
* that index numbers the gate's private per-write snapshot. The rewrite lives
68+
* in `runtime-gate.ts` (`nameKeyFindingPath`); rules keep emitting positional
69+
* paths and need no awareness of it.
6070
*/
6171
export interface LocatedLintIssue extends LintIssue {
6272
/** Human-readable location, e.g. `object "sys_account" · index 'uniq_org_email'`. */

packages/lint/src/runtime-gate.object-writes.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,88 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
285285
expect(advisory!.severity).toBe('warning');
286286
});
287287
});
288+
289+
/**
290+
* #10064 — the WIRE spelling of a collection-resident finding's path.
291+
*
292+
* Maintainer ruling (issue #10064, 2026-08-20): key the top-level collection
293+
* entry by NAME (`objects.acme_invoice.sharingModel`), never by the gate's
294+
* private per-write snapshot index — `buildRuntimeWriteSnapshots` appends the
295+
* written item to an in-memory array the caller has never seen, so
296+
* `objects[417]` resolves against nothing a Studio / MCP / REST receiver
297+
* holds. Single-member write types keep their trivially-stable positional
298+
* form (`flows[0]…` — pinned in `runtime-gate.test.ts`), and nested positions
299+
* within one named item stay positional (they index the author's own
300+
* document).
301+
*/
302+
describe('collection-resident finding paths are name-keyed on the wire (#10064)', () => {
303+
/** Stored siblings so the written item does NOT land at snapshot index 0. */
304+
const SIBLINGS = [
305+
{ name: 'sib_alpha', sharingModel: 'private', fields: { owner: { type: 'text' } } },
306+
{ name: 'sib_beta', sharingModel: 'private', fields: { owner: { type: 'text' } } },
307+
];
308+
309+
it('an object refusal keys the written object by NAME even when siblings occupy the low indexes', () => {
310+
// `security-owd-unset` paths at the whole-object `sharingModel` slot. The
311+
// written item sits at snapshot index 2 (after two stored siblings) — the
312+
// pre-#10064 wire spelling was `objects[2].sharingModel`, an offset into
313+
// the gate's private snapshot.
314+
const result = runRuntimeAuthoringRules({
315+
type: 'object',
316+
item: { name: 'acme_invoice', label: 'Invoice', fields: {} },
317+
context: { objects: SIBLINGS },
318+
});
319+
const owd = result.errors.find((f) => f.rule === 'security-owd-unset');
320+
expect(owd, JSON.stringify(result.errors)).toBeDefined();
321+
expect(owd!.path).toBe('objects.acme_invoice.sharingModel');
322+
});
323+
324+
it('EVERY added finding on the write is name-keyed, not only the first', () => {
325+
const result = runRuntimeAuthoringRules({
326+
type: 'object',
327+
item: { name: 'acme_invoice', label: 'Invoice', fields: {} },
328+
context: { objects: SIBLINGS },
329+
});
330+
for (const f of [...result.errors, ...result.advisories]) {
331+
expect(f.path, `${f.rule} leaked a snapshot index: ${f.path}`).not.toMatch(/^objects\[\d+\]/);
332+
}
333+
});
334+
335+
it('a name that cannot splice into a dotted path keeps the positional spelling — fallback, never a hole', () => {
336+
const result = runRuntimeAuthoringRules({
337+
type: 'object',
338+
item: { name: 'bad name!', label: 'Broken', fields: {} },
339+
context: { objects: SIBLINGS },
340+
});
341+
const owd = result.errors.find((f) => f.rule === 'security-owd-unset');
342+
expect(owd, JSON.stringify(result.errors)).toBeDefined();
343+
expect(owd!.path).toBe('objects[2].sharingModel');
344+
});
345+
346+
it('a permission write name-keys findings attributed to CONTEXT objects too', () => {
347+
// The pre-#8309 phantom shape re-used as a path probe: a permission write
348+
// judged with objects but no sibling permission sets attributes a
349+
// `security-master-detail-ungranted` advisory to the DETAIL OBJECT — a
350+
// context entry, not the written item. Its top-level index is equally a
351+
// snapshot offset, so it is equally name-keyed.
352+
const objects = [
353+
{ name: 'shop_invoice', sharingModel: 'private', fields: { owner: { type: 'text' } } },
354+
{
355+
name: 'shop_invoice_line',
356+
sharingModel: 'private',
357+
fields: {
358+
invoice: { type: 'master_detail', reference: { object: 'shop_invoice' } },
359+
},
360+
},
361+
];
362+
const result = runRuntimeAuthoringRules({
363+
type: 'permission',
364+
item: { name: 'shop_clerk', label: 'Clerk', objects: { shop_invoice: { read: true } } },
365+
context: { objects },
366+
});
367+
const phantom = [...result.errors, ...result.advisories]
368+
.find((f) => f.rule === 'security-master-detail-ungranted');
369+
expect(phantom, JSON.stringify([...result.errors, ...result.advisories])).toBeDefined();
370+
expect(phantom!.path).toMatch(/^objects\.shop_invoice_line(\.|$)/);
371+
});
372+
});

packages/lint/src/runtime-gate.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,60 @@ export function buildRuntimeWriteSnapshots(args: {
396396
return { baseline, candidate };
397397
}
398398

399+
/**
400+
* The collection-resident stack keys whose TOP-LEVEL index the gate rewrites
401+
* to a name key before findings leave it (#10064).
402+
*
403+
* These are the collections a written item lands INSIDE (`TYPE_TO_STACK_KEY`
404+
* routes `object` / `permission` / `book` writes into them) — so a finding's
405+
* `objects[417]` is an offset into this gate's per-write snapshot, an
406+
* in-memory array the caller has never seen and cannot enumerate. Every other
407+
* write type is the sole member of its own collection (`flows[0]` IS this
408+
* write, trivially stable), and `datasets` is context-only — no write type
409+
* maps into it — so both keep their positional spelling.
410+
*/
411+
const NAME_KEYED_STACK_KEYS = ['objects', 'permissions', 'books'] as const;
412+
413+
/**
414+
* Machine names safe to splice into a dotted path. Matches the spec's
415+
* machine-name shape (`snake_case`; see `FieldSchema.name` in
416+
* `packages/spec`) with room for legacy capitalisation — anything else keeps
417+
* the positional spelling rather than minting an unparseable path.
418+
*/
419+
const PATH_SAFE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
420+
421+
const TOP_LEVEL_INDEX = /^(objects|permissions|books)\[(\d+)\](.*)$/;
422+
423+
/**
424+
* `objects[417].sharingModel` → `objects.acme_invoice.sharingModel` (#10064).
425+
*
426+
* The maintainer-ruled wire shape for collection-resident findings: the
427+
* top-level collection index no caller can resolve is replaced by the entry's
428+
* NAME, read from the same candidate snapshot the finding was produced
429+
* against. Nested positions (`.indexes[1]`, `.actions[0]`) stay positional on
430+
* purpose — within one named item they index the author's own document, which
431+
* the receiver holds and can resolve.
432+
*
433+
* Fallback is the positional spelling, never a hole: an entry that is missing,
434+
* unnamed, or whose name will not splice into a dotted path keeps the index.
435+
*
436+
* Applied AFTER the differential, not before it: the fingerprint diff keys on
437+
* the rules' raw positional paths, where two entries can never collide — two
438+
* stored items that (illegitimately) share a name must not have their distinct
439+
* findings merged or cancelled by the rewrite.
440+
*/
441+
function nameKeyFindingPath(path: string, candidate: AnyRec): string {
442+
const m = TOP_LEVEL_INDEX.exec(path);
443+
if (!m) return path;
444+
const [, stackKey, index, rest] = m;
445+
if (!(NAME_KEYED_STACK_KEYS as readonly string[]).includes(stackKey!)) return path;
446+
const collection = candidate[stackKey!] as readonly unknown[] | undefined;
447+
const entry = collection?.[Number(index)];
448+
const name = entry && typeof entry === 'object' ? (entry as AnyRec).name : undefined;
449+
if (typeof name !== 'string' || !PATH_SAFE_NAME.test(name)) return path;
450+
return `${stackKey}.${name}${rest}`;
451+
}
452+
399453
function runRules(
400454
rules: readonly AuthoringRule[],
401455
stack: AnyRec,
@@ -471,7 +525,16 @@ export function runRuntimeAuthoringRules(args: {
471525

472526
const ctx: AuthoringRuleContext = { sduiManifest: args.sduiManifest };
473527
const before = new Set(runRules(rules, snapshots.baseline, ctx).map(fingerprint));
474-
const added = runRules(rules, snapshots.candidate, ctx).filter((f) => !before.has(fingerprint(f)));
528+
const added = runRules(rules, snapshots.candidate, ctx)
529+
.filter((f) => !before.has(fingerprint(f)))
530+
// [#10064] The wire shape: collection-resident findings key their
531+
// top-level collection entry by NAME, not by this gate's private snapshot
532+
// index. Rewritten only on what leaves the gate — the differential above
533+
// ran on the rules' raw positional paths.
534+
.map((f) => {
535+
const path = nameKeyFindingPath(f.path, snapshots.candidate);
536+
return path === f.path ? f : { ...f, path };
537+
});
475538

476539
return {
477540
errors: added.filter((f) => f.severity === 'error'),

packages/lint/src/validate-security-posture.runtime-surface.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,11 @@ describe('validateSecurityPosture at the runtime publish surface (#7576 → #830
204204
});
205205
expect(real.errors.map((f) => f.rule)).toEqual([SECURITY_OWD_UNSET]);
206206
expect(real.errors[0].severity).toBe('error');
207-
expect(real.errors[0].path).toBe('objects[0].sharingModel');
207+
// [#10064] The wire path keys the collection entry by NAME, not by the
208+
// gate's private snapshot index (which would have read `objects[0]` here
209+
// only because the context is empty — with 400 stored objects it would
210+
// have been `objects[400]`, an index no caller can resolve).
211+
expect(real.errors[0].path).toBe('objects.new_object.sharingModel');
208212
expect(real.rulesRun).toContain('validateSecurityPosture');
209213

210214
// And the same write with the OWD authored is clean — the refusal is

packages/spec/src/api/protocol.zod.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,15 @@ export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({
523523
path: z.string().describe(
524524
'Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), '
525525
+ 'so an editor can jump to the offending key. May be empty when the '
526-
+ 'finding is about the item as a whole.',
526+
+ 'finding is about the item as a whole. For the collection-resident '
527+
+ 'write types (`object` / `permission` / `book`) the TOP-LEVEL collection '
528+
+ 'entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by '
529+
+ 'an array index — the gate evaluates against a private per-write '
530+
+ 'snapshot whose indexes no caller can resolve (#10064). Every other '
531+
+ 'write type is the sole member of its own collection, so its `[0]` is '
532+
+ 'trivially stable and stays positional (`flows[0]...`), as do nested '
533+
+ 'positions inside one named item (`objects.acme_invoice.indexes[1]`), '
534+
+ 'which index the author\'s own document.',
527535
),
528536
where: z.string().describe(
529537
'Human-readable location — `flow "leave_approval" · node "approve"`. Prose '

0 commit comments

Comments
 (0)