Skip to content

Commit 94d13f6

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16378-turso-ws-timeout-refusal
2 parents e72a9ef + 434ca2d commit 94d13f6

12 files changed

Lines changed: 419 additions & 60 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`validateStackExpressions` no longer throws on a non-record entry in an object's `fields:` list.
6+
7+
An empty item in a YAML `fields:` list deserialises to `null`, and `buildFieldIndex` cast each member of the list inline (`fields.map(f => (f as AnyRec).name)`) before the `.filter` two calls later could drop it. `Array.isArray` proves the LIST, never its MEMBERS, so linting such a stack failed with `TypeError: Cannot read properties of null (reading 'name')` out of the whole rule instead of reporting anything about the file.
8+
9+
The list is now read through `recordsOf` — the one place that coercion is decided — which drops a non-record member of the array shape whole and in **silence**: it carries no author-written name, so there is nothing to report about it. That matches what the two sibling field readers in the same module (`buildFieldTypeIndex`, `fieldEntries`) already did with the same member, so the three readers now agree. The readable siblings of the junk member are still indexed, so unknown-field findings on that object continue to be reported.
10+
11+
The map shape (`fields: { amount: { … } }`) is unchanged: there the author's key is the field name, which is what this index needs.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): mark `PromptTemplate.system` / `.user` `[EXPERIMENTAL — not enforced]` (#15954, #16321)
6+
7+
Prose only. `Clause-②: no` — no accept-set change, no new/narrowed authorable
8+
key, no matrix declaration. Every value that parsed before parses now, and
9+
every value refused before is refused identically.
10+
11+
Under the #15954 ruling (decision batch #56, option B) the template-typed pair
12+
is **marked, not retired**. Both `.describe()` strings on
13+
`ai/PromptTemplateSchema` now carry the repo's existing
14+
`[EXPERIMENTAL — not enforced]` prefix and state that no runtime renders or
15+
executes the template today:
16+
17+
```ts
18+
system: TemplateExpressionInputSchema.optional().describe('[EXPERIMENTAL — not enforced] System prompt — supports {{var}} interpolation. No runtime renders or executes the template today.'),
19+
user: TemplateExpressionInputSchema.describe('[EXPERIMENTAL — not enforced] User prompt template — supports {{var}} interpolation. No runtime renders or executes the template today.'),
20+
```
21+
22+
**Why an author sees this.** `PromptTemplateSchema` has no consumer outside
23+
`packages/spec`, so the `{{var}}` holes are never interpolated and the declared
24+
`variables` are never checked against them. The ADR-0058 D7 conformance ledger
25+
already recorded that verdict (`template-prompt`, `state: 'experimental'`,
26+
`PARSE ONLY — NO EVALUATOR FOUND`); until now nothing said it at the
27+
declaration, so the generated reference page advertised a capability the
28+
runtime does not deliver.
29+
30+
**What does NOT change.** `.user` remains **required** and `.system` remains
31+
optional — the schema shape is untouched. Optionalising or retiring a required
32+
key is a parse-breaking change and is deliberately left to its own card. No
33+
tombstone and no ADR-0087 entry is owed: nothing is renamed, retired or
34+
re-typed.

content/docs/api/error-catalog.mdx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ title: Error Code Catalog
33
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
44
---
55

6-
ObjectStack uses a structured error system with **9 error categories** and **50 standardized error codes**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
6+
ObjectStack uses a structured error system with **9 error categories** and **51 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
7+
8+
This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
9+
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
10+
boundary, and the catalog carries [error-code ledger](/docs/references/api/error-code-ledger) codes the
11+
enum does not. A translated code is documented under the spelling clients receive, and named in that
12+
entry's cross-reference sentence so the in-process one stays findable.
713

814
<Callout type="info">
915
**Source:** `packages/spec/src/api/errors.zod.ts`
@@ -361,11 +367,16 @@ result set — a response indistinguishable from a successful query.
361367
**Fix:** Delete or reassign dependent records first, then retry the delete.
362368
**Retry:** `no_retry`
363369

364-
### `DUPLICATE_RECORD`
370+
### `UNIQUE_VIOLATION`
365371
**Cause:** A record with the same unique key already exists.
366372
**Fix:** Update the existing record instead, or use a different unique key value.
367373
**Retry:** `no_retry`
368374

375+
The engine throws `DuplicateRecordError`, whose in-process `code` is
376+
`DUPLICATE_RECORD`; the REST door translates that envelope at the boundary, so
377+
every route answers the wire code `UNIQUE_VIOLATION` and the in-process spelling
378+
never crosses HTTP.
379+
369380
### `LOCK_CONFLICT`
370381
**Cause:** The record is locked by another process or user.
371382
**Fix:** Wait for the lock to be released, or contact the lock holder.
@@ -802,7 +813,7 @@ async function handleApiCall() {
802813
| 401 | `authentication` | `UNAUTHENTICATED`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` |
803814
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
804815
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
805-
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `DUPLICATE_RECORD`, `DELETE_RESTRICTED` |
816+
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` |
806817
| 422 | `validation` | `MISSING_REQUIRED_FIELD` on an absent `controlled_by_parent` master reference (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |
807818
| 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` |
808819
| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR` |

content/docs/references/ai/model-registry.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ const result = ModelCapabilitySchema.parse(data);
171171
| **id** | `string` || Unique template identifier |
172172
| **name** | `string` || Template name (snake_case) |
173173
| **label** | `string` || Display name |
174-
| **system** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | System prompt — supports `{{var}}` interpolation |
175-
| **user** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` || User prompt template — supports `{{var}}` interpolation |
174+
| **system** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [EXPERIMENTAL — not enforced] System prompt — supports `{{var}}` interpolation. No runtime renders or executes the template today. |
175+
| **user** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` || [EXPERIMENTAL — not enforced] User prompt template — supports `{{var}}` interpolation. No runtime renders or executes the template today. |
176176
| **assistant** | `string` | optional | Assistant message prefix |
177177
| **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; defaultValue?: any; … }[]` | optional | Template variables |
178178
| **modelId** | `string` | optional | Recommended model ID |
@@ -260,8 +260,8 @@ const result = ModelCapabilitySchema.parse(data);
260260
| **id** | `string` || Unique template identifier |
261261
| **name** | `string` || Template name (snake_case) |
262262
| **label** | `string` || Display name |
263-
| **system** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | System prompt — supports `{{var}}` interpolation |
264-
| **user** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` || User prompt template — supports `{{var}}` interpolation |
263+
| **system** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [EXPERIMENTAL — not enforced] System prompt — supports `{{var}}` interpolation. No runtime renders or executes the template today. |
264+
| **user** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` || [EXPERIMENTAL — not enforced] User prompt template — supports `{{var}}` interpolation. No runtime renders or executes the template today. |
265265
| **assistant** | `string` | optional | Assistant message prefix |
266266
| **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; defaultValue?: any; … }[]` | optional | Template variables |
267267
| **modelId** | `string` | optional | Recommended model ID |

packages/lint/src/non-record-object-entry.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -340,22 +340,22 @@ const SWEPT_COLLECTIONS: readonly SweptCollection[] = [
340340
* "nothing throws" would have had to be deleted or weakened on the day it was
341341
* written, and would then never have caught the next one.
342342
*
343-
* Every entry names a reader OUTSIDE what #15636 could touch:
343+
* It is EMPTY today, and that is a measurement, not an aspiration: no rule in
344+
* the table throws on a non-record member of any collection swept here. Two
345+
* rows have come out since it was written, each because the sweep went red
346+
* demanding a throw that no longer happens — which is the both-directions half
347+
* earning its keep, since neither removal started with anyone going looking:
344348
*
345-
* - `objects[].fields` — `buildFieldIndex` in `validate-expressions.ts:137`,
346-
* which casts inline instead of through a helper, so the `asArray` sweeps
347-
* that produced #15552 and #15636 never saw it. Filed as #15742.
348-
*
349-
* `stack.datasets` was here too, for `indexDatasets` in
350-
* `validate-chart-bindings.ts`. #15741 re-pointed that reader and these
351-
* assertions went red demanding a throw that no longer happens, which is the
352-
* both-directions half earning its keep: the rows came out because the sweep
353-
* failed, not because anyone went looking for them.
349+
* - `stack.datasets` — `indexDatasets` in `validate-chart-bindings.ts`,
350+
* re-pointed by #15741.
351+
* - `objects[].fields` — `buildFieldIndex` in `validate-expressions.ts`, which
352+
* cast each member inline instead of reading through a helper, so the
353+
* `asArray` greps that produced #15552 and #15636 never saw it. It now reads
354+
* the list through `recordsOf` (#15742), which drops a non-record member of
355+
* the array shape whole and in silence, exactly as the file's two sibling
356+
* field readers already did.
354357
*/
355-
const RESIDUAL_THROWS: Readonly<Record<string, readonly string[]>> = {
356-
'objects[].fields · null': ['validateStackExpressions'],
357-
'objects[].fields · undefined': ['validateStackExpressions'],
358-
};
358+
const RESIDUAL_THROWS: Readonly<Record<string, readonly string[]>> = {};
359359

360360
/**
361361
* Where a junk member still draws a finding no author's file justifies — the

packages/lint/src/validate-expressions.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3931,3 +3931,42 @@ describe('structural condition shape (#15662)', () => {
39313931
});
39323932
});
39333933
});
3934+
3935+
describe("validateStackExpressions — a non-record entry in an object's `fields:` list (#15742)", () => {
3936+
// `buildFieldIndex` used to cast each member inline
3937+
// (`fields.map(f => (f as AnyRec).name)`), so an empty YAML list item —
3938+
// which deserialises to `null` — threw `Cannot read properties of null`
3939+
// out of the whole rule. It reads the list through `recordsOf` now, which
3940+
// is the one place that decision is made: an array member that is not a
3941+
// record carries no author-written name, so it is dropped WHOLE and in
3942+
// silence, the same disposition the file's two sibling field readers
3943+
// (`buildFieldTypeIndex`, `fieldEntries`) already had. The sweep in
3944+
// `non-record-object-entry.test.ts` pins the absence of the throw across
3945+
// every rule; these two arms pin what this rule does INSTEAD, which a
3946+
// crash-only sweep cannot say.
3947+
const stackWith = (fields: unknown[], condition: string): Record<string, unknown> => ({
3948+
objects: [{ name: 'crm_account', fields }],
3949+
flows: [{
3950+
name: 'account_flow',
3951+
nodes: [
3952+
{ id: 'start', type: 'start', config: { objectName: 'crm_account' } },
3953+
{ id: 'check', type: 'decision', config: { condition } },
3954+
],
3955+
edges: [],
3956+
}],
3957+
});
3958+
3959+
it('is dropped in silence rather than thrown on, and invents no finding', () => {
3960+
expect(validateStackExpressions(stackWith([null, { name: 'amount', type: 'number' }], 'record.amount > 0'))).toHaveLength(0);
3961+
expect(validateStackExpressions(stackWith([undefined, { name: 'amount', type: 'number' }], 'record.amount > 0'))).toHaveLength(0);
3962+
});
3963+
3964+
it('still indexes the readable siblings — the junk member does not blank the index', () => {
3965+
// The failure mode a bare `try/catch` repair would have produced: no
3966+
// crash, and no field knowledge either, so every unknown-field finding
3967+
// on the object silently stops being reported.
3968+
const issues = validateStackExpressions(stackWith([null, { name: 'amount', type: 'number' }], 'record.amont > 0'));
3969+
expect(issues).toHaveLength(1);
3970+
expect(issues[0].message).toContain('amount');
3971+
});
3972+
});

packages/lint/src/validate-expressions.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,17 @@ function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
139139
if (!name) continue;
140140
const fields = obj.fields;
141141
let names: string[] = [];
142-
if (Array.isArray(fields)) names = fields.map(f => (f as AnyRec).name).filter((n): n is string => typeof n === 'string');
142+
// The LIST shape is read through `recordsOf` (#15742). `Array.isArray`
143+
// proves the list, never its members: an empty item in a YAML `fields:`
144+
// list deserialises to `null`, and the cast this replaced dereferenced it
145+
// before the `.filter` two calls later could drop it. The two sibling
146+
// readers below already guard (`buildFieldTypeIndex` reads `(f)?.name`,
147+
// `fieldEntries` filters before mapping) and both drop such a member in
148+
// SILENCE — it carries no author-written name, so there is nothing to
149+
// report about it — which is what `recordsOf` does for the array shape too.
150+
// The MAP shape keeps `Object.keys`: there the author's KEY is the field
151+
// name, which is exactly what this "did you mean?" index needs.
152+
if (Array.isArray(fields)) names = recordsOf(fields).map(f => f.name).filter((n): n is string => typeof n === 'string');
143153
else if (fields && typeof fields === 'object') names = Object.keys(fields as AnyRec);
144154
// Injected columns come second, de-duplicated by insertion order: a DECLARED
145155
// `owner_id` is the author's field (the registry lets it win), so the

packages/qa/dogfood/test/expression-conformance.ledger.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,9 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
382382
'ai/model-registry.zod.ts:PromptTemplateSchema.system',
383383
'ai/model-registry.zod.ts:PromptTemplateSchema.user',
384384
],
385-
note: 'EXPERIMENTAL — declared prompt templates with no runtime evaluator (ADR-0049). Ownership was checked before classifying rather than assumed: the card that appeared to own these keys is closed as completed, and its delivered diff (`d355c361157`) touched exactly one file, `skills/objectstack-ai/SKILL.md` — it corrected a prose clause that called these keys CEL, and never owned a ledger row. No open card owns them.',
385+
// Ruling: #15954 (decision batch #56, option B for the template family). The tracker ids live
386+
// here, not in the string: `note` is runtime prose (check:doc-authoring, cross-package prose-id leg).
387+
note: 'EXPERIMENTAL — declared prompt templates with no runtime evaluator (ADR-0049), and MARKED as such at the declaration under the marking ruling (option B: this pair is marked, NOT retired). Both positions this row covers — `PromptTemplateSchema.system` and `PromptTemplateSchema.user` — now carry the `[EXPERIMENTAL — not enforced]` prefix in their own `.describe()`, stating that no runtime renders or executes the template today, so an author reading the generated reference page gets the same verdict this row records instead of having to find this ledger. The marking is PROSE ONLY: `.user` remains REQUIRED (no `.optional()`) and `.system` keeps the `.optional()` it already had — optionalising or retiring a required key is parse-breaking and is its own card. Ownership was checked before classifying rather than assumed: the card that appeared to own these keys is closed as completed, and its delivered diff (`d355c361157`) touched exactly one file, `skills/objectstack-ai/SKILL.md` — it corrected a prose clause that called these keys CEL, and never owned a ledger row. No open card owns them.',
386388
},
387389
{
388390
id: 'template-title-format',

packages/spec/src/ai/model-registry.zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ export const PromptTemplateSchema = lazySchema(() => z.object({
118118
label: z.string().describe('Display name'),
119119

120120
/** Template Content */
121-
system: TemplateExpressionInputSchema.optional().describe('System prompt — supports {{var}} interpolation'),
122-
user: TemplateExpressionInputSchema.describe('User prompt template — supports {{var}} interpolation'),
121+
system: TemplateExpressionInputSchema.optional().describe('[EXPERIMENTAL — not enforced] System prompt — supports {{var}} interpolation. No runtime renders or executes the template today.'),
122+
user: TemplateExpressionInputSchema.describe('[EXPERIMENTAL — not enforced] User prompt template — supports {{var}} interpolation. No runtime renders or executes the template today.'),
123123
assistant: z.string().optional().describe('Assistant message prefix'),
124124

125125
/** Variables */

0 commit comments

Comments
 (0)