Skip to content

Commit a8d6b1d

Browse files
huangyiireneclaude
andauthored
refactor(spec): strip internal tracker ids from customer-facing zod refusal messages, and gate their return (#12527)
* refactor(spec): strip internal issue-ids from customer-facing zod refusal messages 15 refusal messages across 9 files under packages/spec/src rendered a tracker id verbatim to an author running `os validate` / hitting a parse refusal. The reader of those strings cannot open the tracker, so the id is a citation-shaped token resolving to nothing. Where the reference is genuinely load-bearing for an internal reader it moves to an adjacent // comment (4 sites); elsewhere it is removed outright — git history keeps the anchor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy * feat(gates): hold the no-tracker-ids convention over spec refusal messages Rule 3 in check-doc-authoring: an AST scan of packages/spec/src for internal issue ids in customer-facing zod refusal messages, inheriting the same ruling Rule 2 applies to the published skill catalog. It parses rather than scanning lines because refusal prose here is written as multi-line string concatenation: the commissioning card's own single-line census command found 1 of the 16 literals in the population. Recognises both the `message:` property and the positional validator-message argument, and asserts it saw message strings at all so a blinded detector reds instead of reporting a clean population it never located. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy * test(spec): pin the refusal substance, not the tracker id Three twins asserted the id as a bare toContain('#7113' / 'objectui#5933' / '#5055'). Each now pins the sentence that carries the claim, plus a negative pin that the message contains no issue id at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy * chore: changeset for the spec refusal-message id strip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f28f00f commit a8d6b1d

16 files changed

Lines changed: 530 additions & 30 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Strip internal tracker ids from the refusal messages an author actually reads (#12124)
6+
7+
Fifteen zod refusal messages across nine `packages/spec/src` files ended a sentence with
8+
an internal issue id. Those strings are printed **at** the author, verbatim, the moment
9+
their metadata is refused — by `os validate`, by a publish gate, by a parse — and the
10+
reader has no tracker to open. A `#NNNN` there is a citation-shaped token that resolves to
11+
nothing, in the one place the sentence most needs to be actionable.
12+
13+
```text
14+
before: A field condition's keys are field names, never $-prefixed operators (#7711).
15+
after: A field condition's keys are field names, never $-prefixed operators.
16+
17+
before: ... refused at authoring time because the query path refuses it too
18+
(400 INVALID_FILTER, #5869).
19+
after: ... refused at authoring time because the query path refuses it too
20+
(400 INVALID_FILTER).
21+
```
22+
23+
Where a customer-resolvable anchor already carried the meaning it was kept and the id
24+
dropped beside it: the second example above keeps `400 INVALID_FILTER`, which is the token
25+
an author can actually match their query-path error against. Where the reference is
26+
load-bearing for an *internal* reader only, it moved to an adjacent `//` comment (four
27+
sites: the endpoint publish gate's two `#5040` section pointers, the summary-field rule's
28+
founding incident, and the interim renderer precedence behind the doubled-redirect
29+
refusal). Elsewhere it is simply gone — git history keeps the anchor.
30+
31+
Text only. **No accept/reject behaviour changes**: the same inputs are refused on the same
32+
schemas with the same issue `code`, `path` and error shape; only the sentence changes.
33+
Test twins that pinned the old wording now pin the new text plus a negative assertion that
34+
the message carries no issue id at all.
35+
36+
The convention is held mechanically from here — `check:doc-authoring` gained a third rule
37+
that parses `packages/spec/src` and reds on an id in any refusal-message string. It parses
38+
rather than scanning lines because refusal prose here is written as multi-line string
39+
concatenation: a single-line `message:.*#[0-9]{3,5}` grep sees 1 of the 16 literals.

packages/spec/src/ai/skill-trigger-condition-value-shape.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ describe('#7113 — the reported shape is refused at authoring time', () => {
6565
it('names the consumer-side coercion as the thing being replaced', () => {
6666
const issue = valueIssue(parse({ field: 'userRole', operator: 'not_in', value: 'admin' }));
6767
expect(issue.message).toContain('coerces the scalar today');
68-
expect(issue.message).toContain('#7113');
68+
// The claim is carried by the SENTENCE, not by a tracker id: this string is
69+
// printed at an author who has no tracker to open.
70+
expect(issue.message).toContain('the contract never declared that spelling');
71+
expect(issue.message).not.toMatch(/(?<![#&])#[0-9]{3,5}(?![0-9A-Za-z])/);
6972
});
7073
});
7174

packages/spec/src/ai/skill.zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ function checkSkillTriggerConditionValueShape(
159159
+ `${value === undefined ? '["…"]' : previewConditionValue([value])} for a single value, `
160160
+ `or use "${operator === 'in' ? 'eq' : 'neq'}" to compare against it. `
161161
+ `An empty list [] is allowed and is a real predicate. The cloud agent runtime `
162-
+ `coerces the scalar today; the contract never declared that spelling (#7113).`,
162+
+ `coerces the scalar today; the contract never declared that spelling.`,
163163
});
164164
return;
165165
}
@@ -174,7 +174,7 @@ function checkSkillTriggerConditionValueShape(
174174
+ `Received ${describeConditionValue(value)} (${previewConditionValue(value)}). `
175175
+ `"${operator}" is an identity comparison, so an array can never match a context `
176176
+ `field and the condition would ${operator === 'eq' ? 'never' : 'always'} fire — `
177-
+ `use "${operator === 'eq' ? 'in' : 'not_in'}" to test membership of that list (#7113).`,
177+
+ `use "${operator === 'eq' ? 'in' : 'not_in'}" to test membership of that list.`,
178178
});
179179
}
180180

packages/spec/src/api/endpoint-publish-gate.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ function targetGate(
354354
return undefined;
355355
}
356356

357+
// Internal anchor for the `proxy` half: the egress/SSRF ruling this defers to
358+
// is #5040 §7-3. It stays in this comment rather than in the message — the
359+
// message is printed to a customer who cannot open the tracker.
357360
return {
358361
path: at('type'),
359362
message:
@@ -362,8 +365,8 @@ function targetGate(
362365
+ 'no path in this repo verifies that a script target is reachable through the automation '
363366
+ 'service — express the logic as a flow (`type: \'flow\'`) whose script node runs your '
364367
+ 'registered function. `proxy` is refused because forwarding to an arbitrary outbound URL is '
365-
+ 'a new egress/SSRF surface that needs its own security ruling before it can be served '
366-
+ '(#5040 §7-3); call the third-party system from a flow instead, where the outbound call is '
368+
+ 'a new egress/SSRF surface that needs its own security ruling before it can be served; '
369+
+ 'call the third-party system from a flow instead, where the outbound call is '
367370
+ 'made by a declared connector. Both stay in the vocabulary and are rejected here rather '
368371
+ 'than parsed and ignored.',
369372
};
@@ -559,11 +562,13 @@ function policyGate(
559562
}
560563

561564
if (cacheTtl !== undefined && endpoint.method !== 'GET') {
565+
// Internal anchor for the GET-only rule: #5040 §3.3. Kept out of the
566+
// message, which is printed to a customer with no tracker access.
562567
return {
563568
path: at('cacheTtl'),
564569
message:
565-
`${named} declares \`cacheTtl\` on a ${endpoint.method} endpoint. \`cacheTtl\` is GET-only `
566-
+ '(#5040 §3.3): it becomes a `Cache-Control` header on a successful response, and a '
570+
`${named} declares \`cacheTtl\` on a ${endpoint.method} endpoint. \`cacheTtl\` is GET-only: `
571+
+ 'it becomes a `Cache-Control` header on a successful response, and a '
567572
+ 'non-GET answer is not a cacheable representation, so the key would be parsed and never '
568573
+ 'take effect. Remove `cacheTtl`, or declare the endpoint as GET if it really is a read.',
569574
};

packages/spec/src/data/filter.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1451,7 +1451,7 @@ const normalizedFieldConditionSchema = () =>
14511451
z.record(z.string(), FieldOperatorsSchema).refine(
14521452
(condition) => !Object.keys(condition).some((key) => key.startsWith('$')),
14531453
{
1454-
message: 'A field condition\'s keys are field names, never $-prefixed operators (#7711).',
1454+
message: 'A field condition\'s keys are field names, never $-prefixed operators.',
14551455
// `abort` so this branch cannot become the union's spokesman. Measured on
14561456
// zod 4.4.3: a union whose other options all abort returns a lone
14571457
// CONTINUABLE failure verbatim, which made `{ $not: { c: <bad> } }` — a

packages/spec/src/integration/connector.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -935,7 +935,7 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() =>
935935
path: ['authentication'],
936936
message: isInstance
937937
? `Provider-bound connector instance '${entry.name}' must not inline secrets via \`authentication\`; reference credentials with \`auth: { type, credentialRef }\` instead (ADR-0097 §3).`
938-
: `Connector '${entry.name}' must not inline secrets via \`authentication\` — a published connector row is stored whole in \`sys_metadata\`, so the credential would land in cleartext (#7990). A catalog descriptor holds no live credentials: drop \`authentication\` (or set \`{ type: 'none' }\`) and describe the auth scheme in \`description\`. A dispatchable instance declares \`provider\` and references its credential with \`auth: { type, credentialRef }\` (ADR-0097 §3).`,
938+
: `Connector '${entry.name}' must not inline secrets via \`authentication\` — a published connector row is stored whole in \`sys_metadata\`, so the credential would land in cleartext. A catalog descriptor holds no live credentials: drop \`authentication\` (or set \`{ type: 'none' }\`) and describe the auth scheme in \`description\`. A dispatchable instance declares \`provider\` and references its credential with \`auth: { type, credentialRef }\` (ADR-0097 §3).`,
939939
});
940940
}
941941
if (!isInstance) {

packages/spec/src/kernel/functional-completeness.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] {
121121
const out: CompletenessFinding[] = [];
122122

123123
if (type === 'summary' && !isRec(def.summaryOperations)) {
124+
// Internal anchor: the founding incident for this rule is cloud#687. It sits
125+
// here rather than in the message — the message is printed to a customer who
126+
// has neither repo's tracker; ADR-0078 is the reference that travels.
124127
out.push({
125128
rule: FIELD_SUMMARY_WITHOUT_OPERATIONS,
126129
severity: 'error',
@@ -129,7 +132,7 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] {
129132
'A `summary` field with no `summaryOperations` computes nothing: the engine\'s '
130133
+ 'summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`), so it '
131134
+ 'reads 0/null everywhere and anything derived from it is stuck at 0 — while every '
132-
+ 'authoring surface reports success. This is the cloud#687 shape ADR-0078 was written for.',
135+
+ 'authoring surface reports success. This is the shape ADR-0078 was written for.',
133136
fix: "summaryOperations: { object: '<child_object>', field: '<child_field>', function: 'sum' }",
134137
});
135138
}
@@ -278,7 +281,7 @@ export function checkWebhookCompleteness(webhook: unknown): CompletenessFinding[
278281
message:
279282
'A webhook with no `triggers` never fires on any path. The auto-enqueuer drops it while '
280283
+ 'building its subscription cache (`auto-enqueuer.ts` — `if (triggers.size === 0) … return '
281-
+ 'null`), and there is no manual fire path to reach it either: `webhook.zod.ts` (#3196) '
284+
+ 'null`), and there is no manual fire path to reach it either: `webhook.zod.ts` '
282285
+ 'records that the `api` trigger was removed because "no manual fire path exists — the only '
283286
+ 'webhook HTTP surface re-queues already-failed deliveries". The webhook materializes into '
284287
+ '`sys_webhook`, looks armed in Setup, and delivers nothing. To disable a webhook use '

packages/spec/src/system/auth-config.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ export const AudienceConfigSchema = lazySchema(() => z.object({
417417
message:
418418
`posture '${posture}' permits self-registration, so the permission set a self-registrant receives ` +
419419
'must be DECLARED (selfRegistrationPermissionSet) — the implicit member_default fallback is retired ' +
420-
'(#11739; declaring member_default explicitly is allowed).',
420+
'(declaring member_default explicitly is allowed).',
421421
});
422422
} else if (value.selfRegistrationPermissionSet === 'admin_full_access') {
423423
ctx.addIssue({

packages/spec/src/ui/action-doubled-redirect.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@ describe('ActionSchema — doubled post-success navigation (#11519)', () => {
4747
// The remedy: one destination, declared in one place.
4848
expect(msg).toMatch(/drop|remove|keep/i);
4949
// The interim renderer precedence this refusal supersedes at authoring
50-
// time (declared wins, objectui#5933) is recorded so an author hitting
51-
// the error understands what happens to metadata published before it.
52-
expect(msg).toContain('objectui#5933');
50+
// time is recorded so an author hitting the error understands what
51+
// happens to metadata published before it. Pinned as the SUBSTANCE —
52+
// which channel wins and which is dropped — rather than as the tracker id
53+
// that used to stand in for it: the id resolved to nothing for the author
54+
// this message is printed at, while the sentence tells them the outcome.
55+
expect(msg).toContain('interim precedence');
56+
expect(msg).toContain('silently ignored');
57+
expect(msg).not.toMatch(/(?<![#&])#[0-9]{3,5}(?![0-9A-Za-z])/);
5358
});
5459

5560
it('is refused through the registered `action` metadata schema too (the parsing door)', () => {

packages/spec/src/ui/action.zod.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,8 +1509,8 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => {
15091509
'An action that declares `confirmText` beside a non-empty `params` shows the user TWO dialogs '
15101510
+ 'for one decision — the confirm, then the param prompt, with nothing sent until the second. '
15111511
+ "Carry the confirm question in the action's top-level `description` instead (it renders under "
1512-
+ 'the param dialog\'s title) and drop `confirmText`: one condition, one wording, one dialog '
1513-
+ '(#7278). Not `ai.description` — that is the LLM-facing tool contract. `confirmText` stays '
1512+
+ 'the param dialog\'s title) and drop `confirmText`: one condition, one wording, one dialog. '
1513+
+ 'Not `ai.description` — that is the LLM-facing tool contract. `confirmText` stays '
15141514
+ 'correct for a param-LESS action, where the confirm IS the only dialog, and for a view\'s '
15151515
+ '`bulkActionDefs`, where the pair renders one dialog by that schema\'s own contract.',
15161516
path: ['confirmText'],
@@ -1555,6 +1555,10 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => {
15551555
// Scoped to `type: 'script'` — the ruled sentence. `opensInNewTab: false`
15561556
// is not the marker (it declares the channel is NOT in use). The corpus was
15571557
// measured at zero doubled producers (#11519), so nothing legal breaks.
1558+
//
1559+
// Internal anchor for the "interim precedence" the message names: objectui#5933
1560+
// is where the renderer-side precedence was ruled. It lives here rather than in
1561+
// the message, which is printed to a customer who has neither repo's tracker.
15581562
if (data.type === 'script' && data.onSuccess && data.opensInNewTab === true) {
15591563
return false;
15601564
}
@@ -1564,9 +1568,9 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => {
15641568
"A `type: 'script'` action declaring BOTH `onSuccess` and `opensInNewTab: true` carries two "
15651569
+ 'post-success destinations for one success: `opensInNewTab` pre-opens a tab for the '
15661570
+ 'HANDLER-RETURNED `{ redirectUrl }`, while `onSuccess.navigate` declares the hop in '
1567-
+ 'metadata. A renderer can perform only one — under the interim precedence (objectui#5933) '
1571+
+ 'metadata. A renderer can perform only one — under the interim precedence '
15681572
+ "the declared `onSuccess` wins and the handler's `redirectUrl` is silently ignored — so the "
1569-
+ 'doubled declaration is refused at authoring time (#11519). Keep `onSuccess` and drop '
1573+
+ 'doubled declaration is refused at authoring time. Keep `onSuccess` and drop '
15701574
+ '`opensInNewTab` (and stop returning `redirectUrl` from the handler), or keep '
15711575
+ '`opensInNewTab` + the handler redirect and drop `onSuccess`. There is no `precedence` '
15721576
+ 'field, by ruling: one destination, declared in one place.',
@@ -1602,7 +1606,7 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => {
16021606
+ 'otherwise, so without the flag it would parse clean and never run (ADR-0078). If a '
16031607
+ 'pre-opened tab is intended, add `opensInNewTab: true`; otherwise drop `newTabUrl` '
16041608
+ '(behavior is unchanged — the lone key was never read). For a STATIC url action, new-tab '
1605-
+ 'behavior is `openIn: "new-tab"`, not this pair (#11842).',
1609+
+ 'behavior is `openIn: "new-tab"`, not this pair.',
16061610
path: ['newTabUrl'],
16071611
}).transform((data, ctx) => lowerRequiresFeature(data, ctx)));
16081612

0 commit comments

Comments
 (0)