Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/spec-refusal-message-issue-ids.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
---
"@objectstack/spec": patch
---

Strip internal tracker ids from the refusal messages an author actually reads (#12124)

Fifteen zod refusal messages across nine `packages/spec/src` files ended a sentence with
an internal issue id. Those strings are printed **at** the author, verbatim, the moment
their metadata is refused — by `os validate`, by a publish gate, by a parse — and the
reader has no tracker to open. A `#NNNN` there is a citation-shaped token that resolves to
nothing, in the one place the sentence most needs to be actionable.

```text
before: A field condition's keys are field names, never $-prefixed operators (#7711).
after: A field condition's keys are field names, never $-prefixed operators.

before: ... refused at authoring time because the query path refuses it too
(400 INVALID_FILTER, #5869).
after: ... refused at authoring time because the query path refuses it too
(400 INVALID_FILTER).
```

Where a customer-resolvable anchor already carried the meaning it was kept and the id
dropped beside it: the second example above keeps `400 INVALID_FILTER`, which is the token
an author can actually match their query-path error against. Where the reference is
load-bearing for an *internal* reader only, it moved to an adjacent `//` comment (four
sites: the endpoint publish gate's two `#5040` section pointers, the summary-field rule's
founding incident, and the interim renderer precedence behind the doubled-redirect
refusal). Elsewhere it is simply gone — git history keeps the anchor.

Text only. **No accept/reject behaviour changes**: the same inputs are refused on the same
schemas with the same issue `code`, `path` and error shape; only the sentence changes.
Test twins that pinned the old wording now pin the new text plus a negative assertion that
the message carries no issue id at all.

The convention is held mechanically from here — `check:doc-authoring` gained a third rule
that parses `packages/spec/src` and reds on an id in any refusal-message string. It parses
rather than scanning lines because refusal prose here is written as multi-line string
concatenation: a single-line `message:.*#[0-9]{3,5}` grep sees 1 of the 16 literals.
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,10 @@ describe('#7113 — the reported shape is refused at authoring time', () => {
it('names the consumer-side coercion as the thing being replaced', () => {
const issue = valueIssue(parse({ field: 'userRole', operator: 'not_in', value: 'admin' }));
expect(issue.message).toContain('coerces the scalar today');
expect(issue.message).toContain('#7113');
// The claim is carried by the SENTENCE, not by a tracker id: this string is
// printed at an author who has no tracker to open.
expect(issue.message).toContain('the contract never declared that spelling');
expect(issue.message).not.toMatch(/(?<![#&])#[0-9]{3,5}(?![0-9A-Za-z])/);
});
});

Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/ai/skill.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -159,7 +159,7 @@ function checkSkillTriggerConditionValueShape(
+ `${value === undefined ? '["…"]' : previewConditionValue([value])} for a single value, `
+ `or use "${operator === 'in' ? 'eq' : 'neq'}" to compare against it. `
+ `An empty list [] is allowed and is a real predicate. The cloud agent runtime `
+ `coerces the scalar today; the contract never declared that spelling (#7113).`,
+ `coerces the scalar today; the contract never declared that spelling.`,
});
return;
}
Expand All@@ -174,7 +174,7 @@ function checkSkillTriggerConditionValueShape(
+ `Received ${describeConditionValue(value)} (${previewConditionValue(value)}). `
+ `"${operator}" is an identity comparison, so an array can never match a context `
+ `field and the condition would ${operator === 'eq' ? 'never' : 'always'} fire — `
+ `use "${operator === 'eq' ? 'in' : 'not_in'}" to test membership of that list (#7113).`,
+ `use "${operator === 'eq' ? 'in' : 'not_in'}" to test membership of that list.`,
});
}

Expand Down
13 changes: 9 additions & 4 deletions packages/spec/src/api/endpoint-publish-gate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,9 @@ function targetGate(
return undefined;
}

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

if (cacheTtl !== undefined && endpoint.method !== 'GET') {
// Internal anchor for the GET-only rule: #5040 §3.3. Kept out of the
// message, which is printed to a customer with no tracker access.
return {
path: at('cacheTtl'),
message:
`${named} declares \`cacheTtl\` on a ${endpoint.method} endpoint. \`cacheTtl\` is GET-only `
+ '(#5040 §3.3): it becomes a `Cache-Control` header on a successful response, and a '
`${named} declares \`cacheTtl\` on a ${endpoint.method} endpoint. \`cacheTtl\` is GET-only: `
+ 'it becomes a `Cache-Control` header on a successful response, and a '
+ 'non-GET answer is not a cacheable representation, so the key would be parsed and never '
+ 'take effect. Remove `cacheTtl`, or declare the endpoint as GET if it really is a read.',
};
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/filter.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1451,7 +1451,7 @@ const normalizedFieldConditionSchema = () =>
z.record(z.string(), FieldOperatorsSchema).refine(
(condition) => !Object.keys(condition).some((key) => key.startsWith('$')),
{
message: 'A field condition\'s keys are field names, never $-prefixed operators (#7711).',
message: 'A field condition\'s keys are field names, never $-prefixed operators.',
// `abort` so this branch cannot become the union's spokesman. Measured on
// zod 4.4.3: a union whose other options all abort returns a lone
// CONTINUABLE failure verbatim, which made `{ $not: { c: <bad> } }` — a
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/integration/connector.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -935,7 +935,7 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() =>
path: ['authentication'],
message: isInstance
? `Provider-bound connector instance '${entry.name}' must not inline secrets via \`authentication\`; reference credentials with \`auth: { type, credentialRef }\` instead (ADR-0097 §3).`
: `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).`,
: `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).`,
});
}
if (!isInstance) {
Expand Down
7 changes: 5 additions & 2 deletions packages/spec/src/kernel/functional-completeness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,9 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] {
const out: CompletenessFinding[] = [];

if (type === 'summary' && !isRec(def.summaryOperations)) {
// Internal anchor: the founding incident for this rule is cloud#687. It sits
// here rather than in the message — the message is printed to a customer who
// has neither repo's tracker; ADR-0078 is the reference that travels.
out.push({
rule: FIELD_SUMMARY_WITHOUT_OPERATIONS,
severity: 'error',
Expand All@@ -129,7 +132,7 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] {
'A `summary` field with no `summaryOperations` computes nothing: the engine\'s '
+ 'summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`), so it '
+ 'reads 0/null everywhere and anything derived from it is stuck at 0 — while every '
+ 'authoring surface reports success. This is the cloud#687 shape ADR-0078 was written for.',
+ 'authoring surface reports success. This is the shape ADR-0078 was written for.',
fix: "summaryOperations: { object: '<child_object>', field: '<child_field>', function: 'sum' }",
});
}
Expand DownExpand Up@@ -278,7 +281,7 @@ export function checkWebhookCompleteness(webhook: unknown): CompletenessFinding[
message:
'A webhook with no `triggers` never fires on any path. The auto-enqueuer drops it while '
+ 'building its subscription cache (`auto-enqueuer.ts` — `if (triggers.size === 0) … return '
+ 'null`), and there is no manual fire path to reach it either: `webhook.zod.ts` (#3196) '
+ 'null`), and there is no manual fire path to reach it either: `webhook.zod.ts` '
+ 'records that the `api` trigger was removed because "no manual fire path exists — the only '
+ 'webhook HTTP surface re-queues already-failed deliveries". The webhook materializes into '
+ '`sys_webhook`, looks armed in Setup, and delivers nothing. To disable a webhook use '
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/system/auth-config.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -417,7 +417,7 @@ export const AudienceConfigSchema = lazySchema(() => z.object({
message:
`posture '${posture}' permits self-registration, so the permission set a self-registrant receives ` +
'must be DECLARED (selfRegistrationPermissionSet) — the implicit member_default fallback is retired ' +
'(#11739; declaring member_default explicitly is allowed).',
'(declaring member_default explicitly is allowed).',
});
} else if (value.selfRegistrationPermissionSet === 'admin_full_access') {
ctx.addIssue({
Expand Down
11 changes: 8 additions & 3 deletions packages/spec/src/ui/action-doubled-redirect.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,9 +47,14 @@ describe('ActionSchema — doubled post-success navigation (#11519)', () => {
// The remedy: one destination, declared in one place.
expect(msg).toMatch(/drop|remove|keep/i);
// The interim renderer precedence this refusal supersedes at authoring
// time (declared wins, objectui#5933) is recorded so an author hitting
// the error understands what happens to metadata published before it.
expect(msg).toContain('objectui#5933');
// time is recorded so an author hitting the error understands what
// happens to metadata published before it. Pinned as the SUBSTANCE —
// which channel wins and which is dropped — rather than as the tracker id
// that used to stand in for it: the id resolved to nothing for the author
// this message is printed at, while the sentence tells them the outcome.
expect(msg).toContain('interim precedence');
expect(msg).toContain('silently ignored');
expect(msg).not.toMatch(/(?<![#&])#[0-9]{3,5}(?![0-9A-Za-z])/);
});

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

Expand Down
5 changes: 4 additions & 1 deletion packages/spec/src/ui/i18n.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,10 @@ describe('I18nLabelSchema', () => {
const issues = JSON.stringify(r.error?.issues);
expect(issues).toContain('invalid_key');
expect(issues).toContain('never by `key`/`defaultValue`');
expect(issues).toContain('#5055');
// The retired form is named in words. It used to be named by a tracker id
// as well, which resolved to nothing for the author reading the refusal.
expect(issues).toContain('the retired key-reference form');
expect(issues).not.toMatch(/(?<![#&])#[0-9]{3,5}(?![0-9A-Za-z])/);
});

it('rejects a lone `defaultValue` with the same named error', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/ui/i18n.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -195,7 +195,7 @@ export const InlineLocaleMapSchema: z.ZodType<
z.string().regex(
INLINE_LOCALE_KEY,
'an inline label map is keyed by BCP-47 locale tags (`en`, `zh-CN`, …) or `default` — '
+ 'never by `key`/`defaultValue`, the retired key-reference form (#5055): nothing looks the key up, '
+ 'never by `key`/`defaultValue`, the retired key-reference form: nothing looks the key up, '
+ 'so both resolvers fall through to the first string value and the raw key is rendered on screen',
),
z.string(),
Expand Down
11 changes: 10 additions & 1 deletion packages/spec/src/ui/view-filter-rule-value-shape.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,7 +60,16 @@ describe('#6227 — the reported shape is refused at authoring time', () => {

it('names the runtime twin, so the two moments are traceable to one rule', () => {
const issue = valueIssue(parse({ field: 'stage', operator: 'in', value: 'won' }));
expect(issue.message).toContain('400 INVALID_FILTER, #5869');
// The traceable token is the runtime's ERROR CODE, not a tracker id — the
// code is what an author sees on the query path and can match this refusal
// against. The id that used to ride beside it resolved to nothing for the
// customer this string is printed to.
expect(issue.message).toContain('400 INVALID_FILTER');
});

it('carries no internal tracker id — the reader of this string cannot open one', () => {
const issue = valueIssue(parse({ field: 'stage', operator: 'in', value: 'won' }));
expect(issue.message).not.toMatch(/(?<![#&])#[0-9]{3,5}(?![0-9A-Za-z])/);
});

it('refuses through an ALIAS spelling too — the fold runs before the check', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/ui/view.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -527,7 +527,7 @@ function checkViewFilterRuleValueShape(
+ `${value === undefined ? '["…"]' : previewFilterValue([value])} for a single value, `
+ `or use ${operator === 'in' ? '"equals"' : '"not_equals"'} to compare against it. `
+ `An empty list [] is allowed and is a real predicate. This is refused at authoring `
+ `time because the query path refuses it too (400 INVALID_FILTER, #5869).`,
+ `time because the query path refuses it too (400 INVALID_FILTER).`,
});
return;
}
Expand All@@ -541,7 +541,7 @@ function checkViewFilterRuleValueShape(
`Operator "${operator}" on field "${field}" requires a [min, max] value array. `
+ `Received ${describeFilterValue(value)} (${previewFilterValue(value)}). `
+ `A range needs exactly two bounds, in order. This is refused at authoring time `
+ `because the query path refuses it too (400 INVALID_FILTER, #5869).`,
+ `because the query path refuses it too (400 INVALID_FILTER).`,
});
}

Expand Down
Loading
Loading