Skip to content

Commit 98fdc7e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15556-subflow-bubble-swallow
2 parents ea169b3 + ec0a6e7 commit 98fdc7e

54 files changed

Lines changed: 4080 additions & 174 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): a flat-manifest bundle no longer collects every seed dataset twice
6+
7+
`AppPlugin.start()` collects seed data from two locations — the top-level
8+
`data` field, then the legacy `manifest.data` for backward compatibility. The
9+
legacy read resolves its base as `this.bundle.manifest || this.bundle`, so on a
10+
FLAT bundle — manifest fields written directly on the bundle rather than nested
11+
under `manifest:`, a shape `AppPlugin` supports by design and this repo's own
12+
tests construct — it re-read the very array the top-level read had just
13+
contributed. Every dataset landed in the collection twice.
14+
15+
`mergeSeedDatasets` is a plain `push` with no de-duplication, so both copies
16+
reached the shared `seed-datasets` registry, the inline boot seed, and every
17+
later per-org replay. For an `upsert` dataset with an `externalId` the second
18+
pass is idempotent and the cost is doubled work; for a `mode: 'insert'` dataset
19+
it is the dataset APPLIED TWICE per boot — measured here as two `insert` calls
20+
for one record.
21+
22+
The legacy read now carries the same reference guard its sibling collector has
23+
always carried: `loadTranslations()` performs the identical two-location read
24+
and skips the legacy half when `manifest.translations` IS the array the top
25+
level already contributed. That asymmetry between the two collectors was the
26+
whole defect, so the repair is the sibling's guard rather than a third spelling
27+
of the same idea.
28+
29+
⛔ Not a removal of the legacy read: a bundle whose `manifest.data` is a
30+
genuinely different array from its top-level `data` still contributes both, and
31+
a bundle that nests its manifest is unaffected either way. Nothing is added to
32+
or removed from any published surface.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects.
6+
7+
`SolutionBlueprintSchema` (what `apply_blueprint` validates against) and `SolutionBlueprintStrictSchema` (the OpenAI-strict structured-output contract the design model generates against) are two declarations of one shape. Their KEYS were pinned by an existing parity test; their VALUES had never been. Every identifier in the lenient schema carried `.regex(/^[a-z_][a-z0-9_]*$/)` and not one identifier in the strict mirror carried it — 20 leaves apart, measured.
8+
9+
The consequence was a build whose approval did nothing. Asked for a CRM, the design model emitted a `company_size` select whose option values came straight off the labels — `1_49` for 「1-49人」. Generating that was legal. Applying it was not: on the turn the user clicked 「确认,开始搭建」 the deterministic confirm replay handed that exact blueprint to `apply_blueprint`, which refused it wholesale (`objects.0.fields.2.options.0.value: Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/`) and staged nothing. The app appeared only because the model noticed the error card and retried with a repaired blueprint the user had never seen.
10+
11+
Every identifier leaf in the strict mirror now carries the same `SNAKE_CASE` constraint the lenient schema enforces — object / field / view / dashboard / widget / app / nav names, `reference`, `nameField`, `columns`, `groupBy`, `measure`, roll-up `object` / `field` / `relationshipField`, condition `field`, and select option `value`. The constraint is emitted into the JSON Schema the model is given (`pattern`), so an out-of-pattern identifier is refused at generation instead of after approval. Option `value` additionally spells out the case that produced the incident: it may never start with a digit, so 「1-49人」 is authored as `size_1_49` — the `label` keeps the human wording untouched, and only the stored value is an identifier.
12+
13+
A new `strict mirror ↔ lenient schema — VALUE parity` test walks both schemas leaf by leaf and fails on any future divergence, the value-side twin of the key-parity gate that already guards this pair.
14+
15+
Refs cloud#1967.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) nothing is renamed, retired or respelled: no metadata key, export or operator changes shape, so `objectstack migrate meta` has nothing to rewrite; an author's remedy is to drop or retarget the mistaken filter condition, which is a change to the query they meant, not to a spelling -->
8+
9+
**BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny.
10+
11+
The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES``BOOLEAN_VALUE_TYPES``CALENDAR_DATE_TYPES``INSTANT_TYPES``CLOCK_TIME_TYPES``STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares.
12+
13+
New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes.
14+
15+
The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set.
16+
17+
What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
The package-publish seed read-back no longer runs a two-attempt org-then-env ladder whose rungs resolve the same row.
6+
7+
`applyPublishedSeeds` — the route-level seed apply behind `POST /packages/:id/publish-drafts`, which runs for protocols that do not self-apply seeds inside `publishPackageDrafts` — read each just-published `seed` body twice when the session had an active organization: once naming the organization, then once env-wide. The comment above it said the first attempt tried the active org and the second fell back, "and resolving the wrong scope here is what silently produced `0 rows loaded`".
8+
9+
That was true when it was written and is not true now. `seed` declares `allowOrgOverride: false`, and `getMetaItem` resolves `organizationIdForMetaRead(request.type, request.organizationId)` once at its top and spends that binding — never the raw argument — on every read beneath it. The predicate answers `undefined` for every non-overridable type, so both rungs asked the engine the same predicates and served the same answer. Measured rather than reasoned: against the shipping protocol over one store, the two requests produce byte-identical engine reads and byte-identical answers on both the hit and the miss branch, and neutering the second rung reddens nothing on a pinned publish-then-read path (a `view` control confirms the same comparison does separate the two rungs for an org-overridable type).
10+
11+
The read is now a single call naming no organization, and the comment states that the scope is decided by the registry flag and the gate inside `getMetaItem` rather than by this call site — matching the sentence the `app` flip in the same file already carries.
12+
13+
One observable changes, and only on the failure branch: `getMetaItem` answers a wrapper rather than a falsy value for a name it cannot resolve, so the second rung was in practice reached only when the read *threw* — where it repeated the identical failing read and appended the same sentence to the client-facing `seedApplied.errors[]` twice. A failed read-back is now reported once. Nothing about which row a publish resolves, or whether its rows load, moves.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/lint": minor
5+
---
6+
7+
A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`.
8+
9+
`evaluateCondition` derives its source as `typeof expression === 'string' ? expression : (expression?.source ?? '')`. For a value that is neither — a number, a boolean, an array — the read yields `undefined`, the `??` supplies `''`, and the empty-source arm returns **`false`**: the "an unauthored branch must not open" rule, applied to a value that was very much authored. Measured: a `decision` node carrying `config: { condition: 42 }` **registered clean** and executed `success: true` with nothing said at any layer; `{ source: 1 }` did not even get that far and threw a bare `TypeError: exprStr.trim is not a function` out of the validator. `config.condition` is also the key a **start node's trigger gate** is read from, so the same value could gate a whole flow shut forever with no signal to the author.
10+
11+
- The new `structuralConditionRefusal` / `STRUCTURAL_CONDITION_SHAPE_REFUSAL` in `@objectstack/spec/automation` are the single shared notion of why, read by both validators so build time and author time cannot disagree about the shape. `registerFlow` throws, naming the node or edge and attributing the finding; `objectstack validate` reports the same refusal as a located `error`.
12+
13+
**This is deliberately NOT the `predicate`-slot rule, and the difference is measured.** A ledger `predicate` slot (`decision.conditions[].expression`, a screen field's `visibleWhen`) is declared `z.string()`, so `PREDICATE_SLOT_STRING_REFUSAL` refuses every non-string including an envelope. Neither structural slot is declared that way: `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm **transforms into** `{ dialect: 'cel', source }` — so after `FlowSchema.parse` every authored edge condition *is* an envelope — and `FlowNodeSchema.config` is an open `z.record` that passes an envelope written at `config.condition` through verbatim, where `evaluateCondition` evaluates it correctly. Both shapes stay accepted here; an envelope with no `dialect`, and an `ast`-carrying one (`ExpressionSchema`'s own `source`-or-`ast` rule), stay accepted too.
14+
15+
**Strings are untouched, deliberately.** A whitespace-only condition still means "not authored" and still answers `false` on both sides — consistent behaviour, ruled correct, not a defect. What a non-empty string *says* is still `validateExpression('predicate', …)`'s verdict, brace trap and all. Only the shape moved.
16+
17+
An app that authored a number, a boolean, an array or a source-less object in a node or edge `condition` now fails to register with a message naming the site; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(objectql,cli): `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064)
7+
8+
A roll-up value has three producers — the insert-time seed, the child-write
9+
recompute, and the one-off backfill — and **declaring a summary field on an
10+
object that already has rows reaches none of them**. For `count`/`sum` the
11+
backfill repairs that as a side effect (every `NULL` is a hole to it). For
12+
`min`/`max`/`avg` it could not: `summaryNullIsBackfillable` decides on the
13+
function alone, so "never computed" and "no child rows" were indistinguishable,
14+
the column stayed `NULL` on every pre-existing parent, and the report said
15+
`filled: 0` — a false all-clear that a timed flow built on the column then
16+
turned into "matches nothing" (the customer case behind cloud#1908).
17+
18+
**What changes** — maintainer ruling on #15064, option A: the caller who holds
19+
the fact gets a way to say it; the predicate and the default run do not move.
20+
21+
- `SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[]``object.field`
22+
roll-ups the caller knows were never computed. A named `min`/`max`/`avg` is
23+
walked like a `count`: every `NULL` parent is recomputed through the same
24+
`aggregateSummaryValue` the engine writes. A parent whose aggregate is the
25+
empty-set reading (`null` — no child rows) already holds the engine's own
26+
value, so it is neither counted as a hole nor written; the scoped run is
27+
therefore idempotent in the same "re-run until it reports zero" sense.
28+
Naming a `count`/`sum` is accepted and changes nothing, so a publish path can
29+
pass every column it just declared without knowing the empty-set list.
30+
- A name that resolves to no roll-up owned by an object the run walks — a typo,
31+
a plain field, or an object `objects` left out — is **refused before any row
32+
is read**, dry run or apply, with an ADR-0112 envelope (`code:
33+
'INVALID_FIELD'`, `status: 400` — the code the projection and write axes
34+
that name a field already answer, while sorting keeps `INVALID_SORT`;
35+
`field` names the first unresolved entry, `fields` all of them). A silent
36+
no-op there would be the same false all-clear this option exists to end.
37+
- `SummaryBackfillReport.recomputedUndefinedOnEmpty: string[]` — the complement
38+
of `skippedUndefinedOnEmpty`, same `object.field (fn)` spelling; `[]` on an
39+
unscoped run. `SummaryBackfillFieldOutcome.fn` widens from `'count' | 'sum'`
40+
to every roll-up function, since a named `max` now appears in `fields`.
41+
- `os migrate summary-nulls --recompute-undefined-on-empty object.field`
42+
(repeatable) passes the scope through; the confirmation prompt names the
43+
columns; `formatSummaryBackfillReport` lists them under "Recomputed on
44+
request" and explains a `NULL` that remains.
45+
46+
**What does not change:** without the option the walk, the writes, every
47+
counter and the human-readable report are byte-for-byte what they were (pinned
48+
against output captured on `main` before this change); `min`/`max`/`avg` stay
49+
out of scope and keep being reported under `skippedUndefinedOnEmpty`; the
50+
predicate `summaryNullIsBackfillable` is untouched, so `os migrate
51+
summary-nulls` keeps its meaning on every deployment. The only visible delta on
52+
an unscoped run is the one additive report key, `recomputedUndefinedOnEmpty: []`.
53+
54+
`minor` for both packages: an optional parameter on a published exported
55+
function, a new report key, and a new CLI flag are each a purely additive
56+
widening of a published surface, which takes at least `minor` (bump-level rule,
57+
2026-09-04); the `fix`-shaped motivation does not lower it.

.github/workflows/lint.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2284,7 +2284,7 @@ jobs:
22842284
# commands and the gate reds (3/2) — which is exactly what it did here.
22852285
# Both existing collectors in this file carry self-tests ONLY, for the
22862286
# same reason. Keep the sweep out of the collector block.
2287-
- name: ADR symbol-anchor gate self-tests
2287+
- name: Symbol-anchor gate self-tests
22882288
run: |
22892289
failed=""
22902290
run_self_test() {
@@ -2299,16 +2299,25 @@ jobs:
22992299
}
23002300
run_self_test node scripts/symbol-anchors.mjs --self-test
23012301
run_self_test node scripts/check-adr-symbol-anchors.mjs --self-test
2302+
run_self_test node scripts/check-scripts-symbol-anchors.mjs --self-test
23022303
if [ -n "$failed" ]; then
23032304
echo ""
2304-
echo "ADR symbol-anchor self-tests — the following FAILED:"
2305+
echo "Symbol-anchor self-tests — the following FAILED:"
23052306
printf "%s" "$failed"
23062307
exit 1
23072308
fi
23082309
23092310
- name: ADR symbol anchors resolve (no line numbers survive)
23102311
run: node scripts/check-adr-symbol-anchors.mjs
23112312

2313+
# The SECOND corpus (#15765), and it is a `defineCorpus` registration over
2314+
# `scripts/**` comment prose — ⛔ not a second resolver. Its own sweep is a
2315+
# separate step for the same `check-step-collectors` reason stated above:
2316+
# a production sweep inside the collector block re-invokes the stub the
2317+
# harness plants and the count comes out 4/3.
2318+
- name: scripts/** symbol anchors resolve (no line citations on tracked targets survive)
2319+
run: node scripts/check-scripts-symbol-anchors.mjs
2320+
23122321
# NOTE: the standing platform test checklist (docs/qa/platform-checklist/)
23132322
# is validated by `pnpm check:platform-checklist`, but by MAINTAINER
23142323
# DECISION it is NOT wired into CI — it runs on a periodic manual cadence

content/docs/api/error-catalog.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,13 @@ reads those as field filters, so one naming no field could only match zero
7575
records and is rejected rather than answered with an empty page — plus every
7676
other read axis that names a field: `select`, `expand` (a real field that holds
7777
no reference gets its own message), `searchFields` (a real field outside the
78-
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
78+
searchable set gets its own message), `groupBy`, and `aggregations[].field`.
79+
Off the request path the same code answers `backfillSummaryNulls`'s
80+
`recomputeUndefinedOnEmpty` (`os migrate summary-nulls
81+
--recompute-undefined-on-empty object.field`) when an entry is not a roll-up
82+
owned by an object the run walks — a typo, a real non-summary field, or a
83+
roll-up on an object `--object` left out are refused alike, one message naming
84+
every unresolved entry and how many objects the run walked.
7985
**Fix:** Check the object schema for valid field names. Use `os meta get object <name>` to inspect the object's fields. If the name was meant as a
8086
*parameter* rather than a field, use the real one — page size is `top` / `$top`
8187
/ `limit`, not `pageSize` / `perPage`; the response's `error` names the

0 commit comments

Comments
 (0)