You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 9f024d8
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec): BlueprintViewSchema and StrictView declare filters and sort, so an AI-built view carries its own filter (#24152)
Fixes#24129
Clause-②: yes (widening)
## What
A solution-blueprint view can now carry the filter its name promises.
`BlueprintViewSchema` (lenient, what `apply_blueprint` parses) and
`StrictView` inside `SolutionBlueprintStrictSchema` (the OpenAI-strict
`generateObject` contract the design model generates against) both
declare `filters` and `sort`. This is ruling A on
objectstack-ai/cloud#2932 (「2932 同意」), as the card restates it; the
cloud ruling text is not readable from this session.
Before this, a plan could say 「overdue only」 in a view's name and
nowhere else, so the applier shipped an unfiltered grid under that name
(objectstack-ai/cloud#2922). Neither key invents a format.
### `filters`: which records the view shows, ANDed
| | Lenient `BlueprintViewSchema.filters` | Strict `StrictView.filters`
|
|:--|:--|:--|
| presence | optional | required, nullable (null = a view over every
record) |
| element | `ViewFilterRuleSchema` itself (identity-pinned in the test)
| inline `StrictViewFilterRule`, keys pinned equal to
`ViewFilterRuleSchema`'s |
| `field` | `string` | `string` (no `SNAKE_CASE` on either side: value
parity) |
| `operator` | `z.preprocess(fold, z.enum(VIEW_FILTER_OPERATORS))`:
canonical, legacy spellings folded | `z.enum(VIEW_FILTER_OPERATORS)`:
canonical only, no fold |
| `value` | optional; string, number, boolean, null, or a list of
strings or numbers | required, nullable; string, number, boolean, or a
list of strings or numbers |
| operator/value shape (`in` takes an array, `between` takes `[min,
max]`, `icontains` takes a non-empty string) | refused by the reused
refinement | not expressed |
Why each strict-side difference exists:
- **`value` nullable, not optional.** Strict mode needs every key listed
in `required`, and null is how this mirror spells "optional". It is also
the spelling the unary operators use. The blueprint tools strip nulls
before the lenient parse, so the lenient rule sees `value` absent, which
it accepts for unary operators.
- **No fold on `operator`.** The lenient `z.preprocess` is a parse-time
transform with no JSON-Schema form. A model emitting the canonical
vocabulary is the alias table's own contract for new producers. The
strict test pins that `eq` is refused at `views.0.filters.0.operator`
with `invalid_value`.
- **No operator/value-shape refinement.** A refinement has no
JSON-Schema form, and the strict mirror precedent already leaves out
what strict mode cannot express (`questions.max(2)`, `seedData`). The
lenient parse that `apply_blueprint` runs refuses these, and a test pins
it (`in` with a scalar, issue at `views.0.filters.0.value`, code
`custom`). The describe on the strict `value` states the shape rules for
the model.
### `sort`: default record order
| | Lenient | Strict |
|:--|:--|:--|
| presence | optional | required, nullable (null = the platform default
order) |
| element | the shared `SortItemSchema` (`shared/enums.zod.ts`,
identity-pinned) | inline `{ field: string, order: SortDirectionEnum }`,
both keys required |
| form | array only; a `"field desc"` string is refused (`invalid_type`
at `views.0.sort`) | array only |
`ListView.sort`'s entry is an inline `strictObject` and is not exported.
`SortItemSchema` is the exported shared sort item, and that entry's own
comment says its titles mirror it: the same `{ field, order }` keys and
the same `asc` / `desc` vocabulary. The two differ in one way. The
`ListView` entry rejects unknown keys and folds `direction` to `order`.
`SortItemSchema` is a plain `z.object`, so it strips unknown keys, which
is the posture of every other blueprint shape. A `{ field, direction }`
entry is still refused: `order` is required and has no default, so it
cannot silently reverse a sort. The blueprint does not accept a string
form, because `ListView.sort` no longer does.
### Describes
Both sides tell the model that a view whose name or label states a
condition (「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」, 「本月」) MUST carry
the rules that make the name true. They give worked rules that use the
platform's declared tokens `{today}` (date macros) and
`{current_user_id}` (context tokens). Both tokens are expanded on both
sides of the wire, and `examples/app-todo`'s overdue list view uses the
same rule pair. The describe prose is deliberately not pinned by a test.
## Tests (`packages/spec/src/ai/solution-blueprint.test.ts`)
- Lenient: carries `filters` and `sort` through the parse; both
optional; identity pins (`filters` element `toBe(ViewFilterRuleSchema)`,
`sort` element `toBe(SortItemSchema)`); a bad operator is refused at
`views.0.filters.0.operator` (`invalid_value`); `in` with a scalar at
`views.0.filters.0.value` (`custom`); a bad `order` at
`views.0.sort.0.order` (`invalid_value`); a string sort at
`views.0.sort` (`invalid_type`).
- Strict: carries rules, including `in` with a list and `is_null` with
null; null accepted for both keys; each key is required (omitting it
gives `invalid_type` at that key); a rule's `value` key is required
(`invalid_union`); `eq` is refused; a bad `order` is refused. The
existing gallery/gantt strict fixture now sends `filters: null, sort:
null`.
- Key parity, extended a level further out (fields, then objects, then
nav items, now VIEWS): the view key sets are equal, with no deliberate
exclusions. The nested filter-rule and sort-entry key sets are equal.
The strict operator enum equals the lenient one, and the strict `order`
enum equals the lenient one. The VALUE-parity walker covers the new
string leaves automatically: `views[].filters[].field` and
`views[].sort[].field` carry no pattern on either side.
## Generated and ledger artifacts
- Regenerated by the generators:
`packages/spec/authorable-surface/ai.json` (+`BlueprintView:filters`,
+`BlueprintView:sort`),
`content/docs/references/ai/solution-blueprint.mdx`,
`docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md` (ai
sites 78 to 80: the two inline strict shapes), and
`skills/objectstack-ai/references/_index.md` (see below).
`api-surface/`, `json-schema.manifest/`, `declaration-map/`,
`export-origins/` and `authorable-surface.base.json` did not move,
because there are no new exports.
- **Hand-edited ledger, as `build-schemas.ts` requires:**
`packages/spec/dropped-refinements.baseline.json` gains
`ai/BlueprintView: ["filters.element"]` and
`views.element.filters.element` on `ai/SolutionBlueprint` (sites 764 to
766, schemas 234 to 235). This is the `ViewFilterRuleSchema` refinement
that the published lenient JSON Schema cannot carry. `ui/ListView`
already declares the same gap as `filter.element`. The strict schema
carries no refinement and drops nothing.
- **The published strict JSON Schema**
(`json-schema/ai/SolutionBlueprintStrict.json`, built locally):
`views.items.required` is now `object, name, label, type, columns,
groupBy, filters, sort`, with `additionalProperties: false`. The rule
item requires `field, operator, value`, and `operator` is the 20-member
canonical enum. The sort item requires `field, order`, and `order` is
the enum `asc, desc`.
### `skills/**`: one generator-owned file moves
`gen:skill-refs` lists each skill's transitive spec modules. The
blueprint now imports `ui/view.zod` and `shared/enums.zod`, so
`skills/objectstack-ai/references/_index.md` gains 7 pointer lines
(`shared/enums.zod`, `shared/http.zod`, and `ui/view.zod` with its
`bulk-action`, `chart`, `i18n` and `sharing` modules). Without it, the
required `check:skill-refs` is red.
- Readings: the changed file goes from 43 to 50 lines. The whole
published skill package (the sum of all `skills/*/SKILL.md`) goes from
4389 to 4389 lines: no SKILL.md is touched.
- Governance, `node scripts/pm/check-governed-merges.mjs --branch
claude/issue-24129-blueprint-view-filters-sort` on head `d896c66e6`:
"governed-surface predicate: 0 of 8 path(s) hit the register after 1
generated-artifact lift(s) (7 surfaces govern objectstack). ✅ NOT
governed — ordinary queue landing applies to a PR with exactly this file
list." The index is a PURE REGENERATION, byte-equal to `pnpm --filter
@objectstack/spec gen:skill-refs` recomputed on this tree. That is the
generator-owned carve-out in that script, and
`governed-surface-guard.yml` installs the toolchain so the queue guard
can recompute the same answer. No hand-authored `skills/**` content
moves. Size: 325 changed lines (+318 / -7), under the 3000 threshold.
## Local verification
- `pnpm --filter @objectstack/spec build`: exit 0. `pnpm --filter
@objectstack/spec exec vitest run --project local --maxWorkers=2`: 692
files, 20969 passed, 1 todo, exit 0. `pnpm --filter @objectstack/spec
typecheck`: exit 0. These ran at `0b87a3245`. The later merge of
`origin/main` (`d896c66e6`) brings only `docs/qa/**` and
`examples/app-showcase/**`, so no spec input moved.
- `pnpm --filter @objectstack/spec check:generated`: "✓ All 14 generated
artifacts are up to date", on `d896c66e6`.
- `node scripts/pm/dispatch-gates.mjs --commands` derived 113 commands,
and all 113 ran on `d896c66e6` with exit 0. `--ran` reconciliation: "113
derived, 113 run, 0 NOT-MEASURED, 0 UNRUN". On the first pass (at
`0b87a3245`), seven gates refused with exit 3 because a prerequisite was
unbuilt or a fixture commit was not in the shallow clone. They were
re-run green after `turbo run build` of formula, lint, client,
client-react and objectql, and a depth-1 fetch of the fixture commit.
- `pnpm check:dev-prereqs` (local-only family): exit 0.
- Not run locally, left to CI: the `test:repo` project, the integration
tiers, and the path-scheduled CI jobs `dispatch-gates` lists (Test Core
shards, Build Core, Build Docs, dogfood).
## Acceptance notes
- **Key name.** The blueprint key is `filters`, as the ruling and the
card name it. The runtime `ListView` key is `filter`. The applier
(cloud, not readable here) is what expands a blueprint view into a
`ListView` body, so the mapping between the two spellings lives there.
The lenient blueprint schemas are `z.object`s and strip undeclared keys,
so a hand-authored blueprint view that writes the singular `filter`
loses it silently. The same is true of every other undeclared blueprint
key, and the strict path cannot emit an undeclared key at all.
- **The strict mirror's own accept set narrows.** A strict document
without `filters` / `sort` on a view parsed before and is refused now.
That holds for every key ever added to this mirror (`viewName`,
`nameField`, `sharingModel`, `expression` all shipped the same
required-nullable way, under Minor Changes). The mirror is a generation
contract, so its only producer is the model, which receives the new JSON
Schema. It is declared `Clause-②: yes (widening)` as the card rules. The
contract review may weigh this.
- **In-repo consumers** (`git grep` on this head, outside
`packages/spec`): only a comment in
`packages/cli/src/lint/metadata-eval.ts` (the type name
`SolutionBlueprint`), plus the generated docs. The real producer and
consumer are cloud's `propose_blueprint` / `apply_blueprint`. Its
interim `.extend()` (cloud PR #2931) is not readable here, and its key
spelling was not compared.
- **Surface beyond the claim's file list**, all mechanically required:
the dropped-refinements ledger lines, the strictness-ledger count shard,
and the skill index regeneration.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HSDGREvqxQUt7K7pjHXMyC)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec): a solution blueprint view declares `filters` and `sort`, so a view named by a condition can carry that condition
6
+
7
+
Clause-②: yes (widening)
8
+
9
+
`BlueprintViewSchema` and the strict structured-output mirror `SolutionBlueprintStrictSchema.views[]` gain two keys. Before this, a blueprint could say "overdue only" in a view's name and nowhere else, and the built view showed every record.
10
+
11
+
-**`filters`** — which records the view shows, as list-view filter rules `[{ field, operator, value }]`, ANDed.
12
+
- Lenient (`BlueprintViewSchema`): optional, and the array element IS `ViewFilterRuleSchema` (the rule `ListView.filter` declares), so it takes the same operators, folds the same legacy spellings, and refuses the same operator/value-shape mismatches (`in` without an array, `between` without `[min, max]`, `icontains` without a non-empty string).
13
+
- Strict (`SolutionBlueprintStrictSchema`): required and nullable (null = a view over every record). Each rule carries the same three keys, every one present: `operator` is the canonical vocabulary only (`VIEW_FILTER_OPERATORS`, no legacy shorthand such as `eq`), and `value` is nullable (null for the unary operators). The operator/value-shape coupling is not in the strict mirror's JSON Schema; the lenient parse refuses it.
14
+
-**`sort`** — the default record order, `[{ field, order }]` with `order``asc` or `desc`, the first entry sorting first. Lenient: optional, the element is the shared `SortItemSchema` (the `{ field, order }` pair `ListView.sort` declares; array only, a `"field desc"` string is refused). Strict: required and nullable, `{ field, order }` with both keys present.
15
+
- Both describes tell the model that a view whose name or label states a condition ("overdue", "my open") must carry the matching filter, with worked rules using the platform tokens `{today}` and `{current_user_id}`.
16
+
- Additive only: every blueprint that parsed before still parses with the lenient schema. A producer of the STRICT shape must now send `filters` and `sort` on each view (null when it has none), as with every other key in that mirror.
Copy file name to clipboardExpand all lines: content/docs/references/ai/solution-blueprint.mdx
+25Lines changed: 25 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -271,6 +271,27 @@ const result = BlueprintAppSchema.parse(data);
271
271
|**type**|`Enum<'list' \| 'form' \| 'kanban' \| 'calendar' \| 'gallery' \| 'gantt'>`| optional (default: `"list"`) | View kind. Pick the surface that fits the data: "gallery" for a visual card/cover browse when the user asks for a 画廊/相册/卡片墙/封面/海报/图集 (a gallery / card wall / cover / poster grid) or the object has an image/avatar/file field worth showing as a card cover; "gantt" for a 甘特图/时间线/排期 (timeline / schedule) when the object has BOTH a start and an end date field; "kanban" for a board grouped by a status/select field; "calendar" for a single-date schedule; "form" for a record editor; else "list". |
272
272
|**columns**|`string[]`| optional | Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column. |
273
273
|**groupBy**|`string`| optional | REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows). |
274
+
| **filters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Which records this view shows: the list-view filter rules [`{ field, operator, value }`] the built view carries, ANDed. REQUIRED whenever the view's name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [`{ field: "due_date", operator: "less_than", value: "{today}" }`, `{ field: "status", operator: "not_equals", value: "completed" }`]; my open → [`{ field: "owner", operator: "equals", value: "{current_user_id}" }`, `{ field: "status", operator: "equals", value: "open" }`]. WITHOUT it the view shows EVERY record under a name that promises a subset. For a select field compare its option VALUE, never its label. Omit for a view that genuinely spans every record. |
275
+
|**sort**|`{ field: string; order: Enum<'asc' \| 'desc'> }[]`| optional | Default record order: [`{ field, order }`] with order "asc" or "desc"; the first entry sorts first (e.g. an overdue list → [`{ field: "due_date", order: "asc" }`]). Omit for the platform default order. |
276
+
277
+
### Nested Shape: `BlueprintView.filters[number]`
278
+
279
+
View filter rule
280
+
281
+
| Property | Type | Required | Description |
282
+
| :--- | :--- | :--- | :--- |
283
+
|**field**|`string`| ✅ | Field name to filter on |
|**value**|`string \| number \| boolean \| null \| (string \| number)[]`| optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |
286
+
287
+
### Nested Shape: `BlueprintView.sort[number]`
288
+
289
+
Sort field and direction pair
290
+
291
+
| Property | Type | Required | Description |
292
+
| :--- | :--- | :--- | :--- |
293
+
|**field**|`string`| ✅ | Field name to sort by |
294
+
|**order**|`Enum<'asc' \| 'desc'>`| ✅ | Sort direction |
274
295
275
296
276
297
---
@@ -324,6 +345,8 @@ const result = BlueprintAppSchema.parse(data);
324
345
|**type**|`Enum<'list' \| 'form' \| 'kanban' \| 'calendar' \| 'gallery' \| 'gantt'>`| optional (default: `"list"`) | View kind. Pick the surface that fits the data: "gallery" for a visual card/cover browse when the user asks for a 画廊/相册/卡片墙/封面/海报/图集 (a gallery / card wall / cover / poster grid) or the object has an image/avatar/file field worth showing as a card cover; "gantt" for a 甘特图/时间线/排期 (timeline / schedule) when the object has BOTH a start and an end date field; "kanban" for a board grouped by a status/select field; "calendar" for a single-date schedule; "form" for a record editor; else "list". |
325
346
|**columns**|`string[]`| optional | Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column. |
326
347
|**groupBy**|`string`| optional | REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows). |
348
+
| **filters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Which records this view shows: the list-view filter rules [`{ field, operator, value }`] the built view carries, ANDed. REQUIRED whenever the view's name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [`{ field: "due_date", operator: "less_than", value: "{today}" }`, `{ field: "status", operator: "not_equals", value: "completed" }`]; my open → [`{ field: "owner", operator: "equals", value: "{current_user_id}" }`, `{ field: "status", operator: "equals", value: "open" }`]. WITHOUT it the view shows EVERY record under a name that promises a subset. For a select field compare its option VALUE, never its label. Omit for a view that genuinely spans every record. |
349
+
|**sort**|`{ field: string; order: Enum<'asc' \| 'desc'> }[]`| optional | Default record order: [`{ field, order }`] with order "asc" or "desc"; the first entry sorts first (e.g. an overdue list → [`{ field: "due_date", order: "asc" }`]). Omit for the platform default order. |
@@ -387,6 +410,8 @@ const result = BlueprintAppSchema.parse(data);
387
410
|**type**|`Enum<'list' \| 'form' \| 'kanban' \| 'calendar' \| 'gallery' \| 'gantt'> \| null`| ✅ | View kind, or null for list. "gallery" = visual card/cover browse (画廊/相册/卡片墙/封面/海报, or an object with an image/avatar/file field); "gantt" = timeline/schedule (甘特图/时间线/排期, object with BOTH a start and an end date field); "kanban" = board grouped by a status/select field; "calendar" = single-date schedule; "form" = record editor. |
388
411
|**columns**|`string[] \| null`| ✅ | Field names shown as columns, or null. For a gallery, INCLUDE the image/avatar/file field (becomes the card cover); for a gantt, INCLUDE the start date column before the end date column. |
389
412
|**groupBy**|`string \| null`| ✅ | REQUIRED for kanban: the select/status field whose options become the board columns (e.g. "stage"). Optional for gantt (groups leaf tasks). Null for list/form/calendar/gallery. |
413
+
|**filters**|`{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value: string \| number \| boolean \| (string \| number)[] \| null }[] \| null`| ✅ | Which records this view shows: list-view filter rules [`{field, operator, value}`], ANDed, or null for a view over EVERY record. REQUIRED whenever the view's name or label states a condition — an 「overdue / 逾期」, 「my open / 我的未完成」, 「待审批」 or 「本月」 view MUST carry the rules that make its name true, e.g. overdue → [`{field:"due_date",operator:"less_than",value:"{today}"}`,`{field:"status",operator:"not_equals",value:"completed"}`]; my open → [`{field:"owner",operator:"equals",value:"{current_user_id}"}`,`{field:"status",operator:"equals",value:"open"}`]. Null under such a name ships a view that shows every record while its name promises a subset. |
414
+
|**sort**|`{ field: string; order: Enum<'asc' \| 'desc'> }[] \| null`| ✅ | Default record order: [`{field, order}`] with order "asc" or "desc"; the first entry sorts first (e.g. an overdue list → [`{field:"due_date",order:"asc"}`]), or null for the platform default order. |
Copy file name to clipboardExpand all lines: packages/spec/dropped-refinements.baseline.json
+9-3Lines changed: 9 additions & 3 deletions
Original file line number
Diff line number
Diff line change
@@ -2,8 +2,8 @@
2
2
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
0 commit comments