Skip to content

Commit b7a2681

Browse files
committed
Merge origin/main into claude/issue-19012-size-threshold-human-merge
One conflicted file, scripts/pm/check-governed-merges.mjs: the tiered register from main meets the SIZE predicate on this branch. Both sides kept: GOVERNED_TIER_* beside HUMAN_MERGE_LINE_THRESHOLD; `tier` beside `humanMerge` on the post-lift verdict; the Tier H block prints its landing-tier line and then the size line; the Tier S block gains the same size line; the self-test summary carries both suffixes; the roster floor is recounted to the 28 batteries the merged file declares (27 and 26 on the two sides, none lowered). The size line's "same terminal" now names Tier H, the terminal the rules layer keeps under the tiers. SKILL.md, the ratchet ceiling and landing-operations.md merged clean: 813 lines, ceiling 813. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF
2 parents 7fdd61c + 1047fe1 commit b7a2681

323 files changed

Lines changed: 24003 additions & 6692 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for authored metadata** — an ADR-0031 structured region body (`loop.config.body`, a `parallel` branch, `try_catch`'s `try` / `catch`) now refuses two node populations at parse: a node whose TYPE parks the run on every execution, and an `end` node (#15646, absorbing #18112).
6+
7+
Clause-②: yes
8+
9+
The flow accept set shrinks for five node types inside region bodies — shapes the runtime never honoured. Both refusals are the authoring-time enforcement of a limit the engine already holds at run time and #3267 ruled 禁: **a region body runs synchronously inside the enclosing run, so it can neither park that run nor terminate it.**
10+
11+
```
12+
✗ nodes.1.config.body.nodes.0.type: A `approval` node may not sit inside a structured region —
13+
`loop 'sweep' body → try_catch 'guard' try` is a region body and the `approval` node `sign_off`
14+
is inside it. A region body runs synchronously and cannot durably pause …
15+
```
16+
17+
**What is refused**
18+
19+
- **A node that pauses on EVERY execution** — `screen`, `wait`, `approval`, `approval_revise`.
20+
- **An `end` node**, whatever its `outcome`. An `end` in a region was a no-op, and a refusing one was converted into a region error at the boundary; neither is what the author wrote.
21+
22+
**⛔ What is deliberately NOT refused: `subflow` and `map`.** Their shipped executors also declare `supportsPause: true`, but they pause exactly when the child flow their `config.flowName` names pauses — a **different metadata record**, not in hand while this flow is parsed. Refusing them by type would also refuse `loop { map(synchronous child) }`, a shape that runs correctly today and is covered by an existing regression suite. A parse-time rule refuses what is statically wrong; a region-contained node that actually suspends is a fact only the run holds. **Nothing an author wrote with a region-nested `map` or `subflow` needs editing for this release.**
23+
24+
**Why it was silent, measured.** The engine converts a suspension raised inside a region into an error — but the executor has already written its progress state into the ENCLOSING scope by then. Contain that error in a `try_catch` and the residue is read back as progress by the next entry to the same node. On a real `AutomationEngine`, `loop { try_catch { map(pausing child) } }` over 3 iterations × 2 items: not one item's subflow completed, only two of three iterations reached the catch, and iteration 3 read `started === collection.length`, ran nothing, and returned `success` with `summary.failed = 0`. ⚠️ Read that for the MECHANISM, not for this change's reach — the shape it was measured on is a `map`, and making that run's refusal loud is a separate change to the automation engine, not this one.
25+
26+
### Migration — FROM → TO
27+
28+
| You wrote | Write instead |
29+
| --- | --- |
30+
| `loop { body: [ …, end ] }` | `loop { body: [ … ] } → end` — give the region a normal exit and put the terminator, with its `outcome` / `message`, on the top-level graph |
31+
| `loop { body: [ wait ] }` | a top-level `wait`, with the top-level graph as the repeating construct — a region body cannot park the run, so the nested form never waited |
32+
| `parallel { branches: [ [ approval ] , … ] }` | put the `approval` on the top-level graph and fan out around it, or split the branch's pausing half into a `subflow` the top-level graph calls |
33+
34+
The one-line fix is always the same: **move the node onto the top-level graph and route the region's exit to it.** ⛔ Not mechanically convertible — hoisting a node out of a region is a graph rewrite (new edges, a changed exit, sometimes a deleted container) and which shape the author meant is an intent no artifact records, so this ships as an ADR-0087 D3 structured TODO rather than a D2 conversion.
35+
36+
<!-- adr-0087: registered structured-region-body-pause-and-end-refused -->
37+
38+
**⚠️ Two boundaries this refusal does not reach, stated rather than discovered.** A pausing node type contributed by a **plugin** is not refused: ADR-0018 left the node-type namespace open and a parse has no registry. A region nested past **`MAX_REGION_DEPTH` (32)** is not judged: the parse walk stops there, and unlike a duplicate node id there is no second spec refusal behind it. For both, the engine's run-time refusal is the only one — unchanged by this change, and not fixed by it.
39+
40+
⛔ No engine source is edited. What the refusal does to the run time is stated rather than left to be discovered: `AutomationEngine.registerFlow` and the ADR-0087 stored-row rehydration seam both go through `FlowSchema.parse` (`canonicalizeStoredFlow`), so a flow carrying a refused shape no longer registers or rehydrates — it is met at LOAD, not at the region boundary, and a stored row that carries one stops loading until it is rewritten. The engine's own run-time refusals for these shapes stay in place but are reachable only through the two boundaries above; for the `end` arm those are the only remaining path, because the refusal signal it answers is raised at exactly one site — an `end` node whose `outcome` is `refused`.
41+
42+
**Published surface.** `FLOW_PAUSE_CAPABLE_NODE_TYPES` is published with the four types above. ⚠️ Read its contents, not its name: it is the UNCONDITIONALLY pausing set, not every type that can pause — `subflow` and `map` declare `supportsPause: true` and are deliberately absent, for the reason above. The identifier is unchanged, so this release removes no export.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/formula": minor
4+
---
5+
6+
feat(spec)!: every engine-evaluated expression slot requires a non-blank `source` — the #15430 rule generalised from the flow-node ledger to the other 36 declaring positions (#15811, decision batch #122 item 2)
7+
8+
<!-- adr-0087: registered evaluated-expression-slots-source-required -->
9+
10+
**BREAKING** accept-set narrowing on 36 published metadata slots. Each of them
11+
composed `ExpressionInputSchema` and now composes `EvaluatedExpressionInputSchema`,
12+
so an envelope carrying only `ast` (`{ dialect: 'cel', ast: … }` with no `source`)
13+
and a `source` that is blank after trimming — through the envelope key or through
14+
the bare-string shorthand — are refused at the door instead of parsing and then
15+
faulting at run time. The prescription is registered under protocol major 18 as
16+
the semantic migration `evaluated-expression-slots-source-required`.
17+
18+
**⚠️ Graded `minor`, not `major`, and the ruling said `major`.** Decision batch
19+
#122 item 3 ordered a 「`major` changeset」. This repo's launch-window convention
20+
ships breaking changes as `minor` while the fixed group versions in lockstep, and
21+
`scripts/check-changeset-no-major.mjs` enforces it: a `major` marker here would
22+
promote all ~70 packages to a whole-stack major release, which is a release act.
23+
The convention's own written carriers for breaking-ness are used instead and both
24+
are present — this **BREAKING** banner and the ADR-0087 disposition above. The
25+
ruling's substance (a breaking narrowing, carried by an ADR-0087 semantic
26+
migration entry) is delivered; only the marker differs, and it differs because a
27+
repo gate forbids the marker.
28+
29+
**What is NOT narrowed.** `ExpressionSchema` / `ExpressionInputSchema` remain the
30+
persistence contract (`source` OR `ast`), by item 2 of the same ruling, and so
31+
does `PredicateInputSchema`, which is a plain alias of the latter. A slot that
32+
only PERSISTS an envelope is untouched; the narrowing is at the slots an engine
33+
EVALUATES. An `ast` carried BESIDE a string `source` stays admitted everywhere.
34+
35+
**The population was re-derived, not inherited.** By identity — a negative
36+
lookaround on identifier characters, so `CronExpressionInputSchema` and
37+
`TemplateExpressionInputSchema` cannot leak in as substrings — over
38+
`packages/spec/src`, non-test: 34 declaring source lines, two of which are
39+
file-local alias consts (`ui/action.zod.ts` `ActionConditionInputSchema`,
40+
`system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema`) that mount two
41+
slots each, giving **36 declaring positions**. Three of them reach the schema as a
42+
union member rather than head-of-declaration (`RecordAlertProps.visible`,
43+
`ServiceLevelIndicator.successCriteria`, `TraceSamplingConfig.composite[].condition`).
44+
45+
On **two of those three the sibling arm is untouched**: `RecordAlertProps.visible`
46+
still takes a boolean literal, and `ServiceLevelIndicator.successCriteria` still
47+
takes its structured `{ threshold, operator, percentile? }` object — including one
48+
that happens to carry a `dialect` key.
49+
50+
⚠️ **On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm
51+
narrows too, and deliberately.** Its structured-filter arm is a bare
52+
`z.record(z.string(), z.unknown())`, which accepted `{ dialect: 'cel', ast }` as an
53+
ordinary filter — so swapping the expression arm changed nothing at all there. That
54+
arm now declines any object carrying a `dialect` key, and six shapes the base
55+
accepted THROUGH THAT ARM ALONE (measured: the base's `ExpressionInputSchema`
56+
refused every one of them) are refused at this slot:
57+
58+
| authored `condition` | base | now |
59+
|---|---|---|
60+
| `{ dialect: 'cel' }` | accepted | refused |
61+
| `{ dialect: 'js', source: 'x' }` | accepted | refused |
62+
| `{ dialect: 'nope', source: 'x' }` | accepted | refused |
63+
| `{ dialect: 'cel', source: 5 }` | accepted | refused |
64+
| `{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }` | accepted | refused |
65+
| `{ dialect: 'zzz', foo: 1 }` | accepted | refused |
66+
67+
FROM → TO at that slot: if the value really is a **structured filter**, drop the
68+
`dialect` key (`{ dialect: 'cel', service: 'api' }` → `{ service: 'api' }`); if it is
69+
an **expression**, give it a dialect this platform evaluates and a non-blank `source`
70+
(`{ dialect: 'js', source: 'x' }` → `{ dialect: 'cel', source: 'x' }`). A structured
71+
filter that carries no `dialect` key — `{}`, `{ service: 'api' }`,
72+
`{ attributes: { 'http.route': '/v1/orders' } }` — is accepted exactly as before.
73+
74+
**Why an authoring-time refusal and not a run-time one.** Measured at the
75+
chokepoint, `celEngine.evaluate` never silently succeeds on either shape — it
76+
returns a `parse` fault — so what happened next was decided entirely by the
77+
slot's fail policy, and the two halves of that population fail in opposite
78+
directions: fail-CLOSED slots (`ObjectFieldGroup.visibleWhen`,
79+
`RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two
80+
settings-manifest `visible` slots) hid a group, a row button, or silently excluded
81+
every selected record from a bulk run and reported them as *skipped*; fail-SOFT
82+
slots left a gate that had stopped gating. Nothing in between said a word: the
83+
authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only
84+
envelope and 0 on a blank `source`, against two control legs that each measured 1.
85+
86+
**`@objectstack/formula` gains `printCelAst(ast)`** — the inverse of
87+
`parseCelToAst`, and the lossless half of the migration: an `ast`-only CEL
88+
envelope is printed back to surface syntax mechanically, with no judgment asked of
89+
the author. It is lossless about MEANING, not bytes (the printer re-renders from
90+
the parse tree, so `'x'` comes back as `"x"`), and it answers `null` — never a
91+
guess — for anything it cannot round-trip through the platform's own bounded
92+
parser. That `null`, and every blank `source`, are what the semantic migration
93+
entry's structured TODO covers.
94+
95+
**The published TypeScript interface `RowCrudPredicates` narrows with it**
96+
(`Expression | ExpressionInput` → `EvaluatedExpression | EvaluatedExpressionInput`),
97+
because it mirrors the two `RowCrudActionOverride` slots and a type that still
98+
promised an `ast`-only envelope would advertise what the schema now refuses.
99+
100+
**So do the four expression constructors — `expression()`, `cel`, `tmpl`, `cron`
101+
(and therefore the `F` / `P` aliases) — which now return `EvaluatedExpression`
102+
instead of `Expression`.** Each one assigns a `string` to `source`
103+
unconditionally, so the wider return type described none of them; it was slop
104+
that cost nothing until an evaluated slot began requiring `source`, at which
105+
point ``visibleWhen: P`…` `` — the spelling the spec's own docblock teaches —
106+
stopped type-checking, and `@objectstack/platform-objects` failed its DTS build
107+
on exactly that. `EvaluatedExpression` is assignable to `Expression`, so every
108+
persistence-contract slot keeps accepting these values unchanged; what the
109+
narrower return type adds is that an evaluated slot accepts them too. An author
110+
who genuinely has no `source` was never calling these constructors — an
111+
`ast`-only envelope is an object literal, and an evaluated slot refuses it on
112+
purpose.
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+
`element:text.variant` accepts the nine values objectui's text node publishes — `h1`–`h6`, `body`, `caption`, `overline` — and still accepts `heading` and `subheading` (#17108).
6+
7+
Clause-②: yes (widening)
8+
9+
Release 1 of 2 for the objectui#7450 convergence (director batch #71, 2026-09-07, maintainer verbatim 「其他同意」), split across two releases by the maintainer's decision of 2026-09-09, option B. This release is **additive only**: the accepted set grows by seven and nothing is refused that was accepted before, so an out-of-repo author can converge on a released pin before any spelling stops working.
10+
11+
Measured on the 17.3.0 declaration, per value, through `ElementTextPropsSchema.safeParse`: `h1`–`h6` and `overline` were refused with `invalid_value`; they are accepted now. `heading`, `subheading`, `body` and `caption` were accepted and are accepted now. A value outside the eleven — `small` — is still refused with `invalid_value` at path `variant`, so the enum remains a closed set rather than having stopped judging `variant` at all.
12+
13+
- **`.optional().default('body')` is kept, deliberately.** An `element:text` node parsed without a `variant` still materialises `variant: 'body'`, exactly as before. Absence is the one thing a widening must not move, and the `ui:text` side of the platform deliberately does *not* synthesise `body` for an absent `variant` (objectui#6942) — that asymmetry is pre-existing and is left where it was.
14+
- **⛔ Nothing is retired.** `heading` and `subheading` become named refusals carrying migration hints in **release 2**, which is a separate card and is blocked on a value-level retirement mechanism that does not exist yet: `retiredKey()` and ADR-0087 D2 retire a *key*, not a *value*. Authors who want to move early can write `h2` for `heading` and `h3` for `subheading`; neither spelling stops working in this release.
15+
- **No renderer changes here.** `element:text`'s renderer, its designer inspector options and its i18n rows are objectui's, on the released pin, and land on objectui's side of the sequence.
16+
17+
Generated projections follow the declaration: `api-surface-declarations/ui.txt` gains the seven members on `ElementTextPropsSchema` and on `ComponentPropsMap['element:text']`, and the `content/docs/references/ui/component.mdx` property table widens. `check:api-surface` reports nothing removed or narrowed.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for authored metadata** — a `$between` range now requires two endpoints that are present and non-empty. A blank bound (`''` or an absent `undefined` bound, at either side) is refused at the authoring door, and the refusal names the blank side (#18012).
6+
7+
Clause-②: yes
8+
9+
Maintainer ruling A on decision batch #146 item 5, 2026-09-17 「146 同意」.
10+
11+
## What changed, and why it is a new rule rather than a repair
12+
13+
`FieldOperatorsSchema.safeParse({ $between: [1, ''] })` answered `success: true` — measured on the card against spec 17.4.0 and re-measured on `main` before this change. That acceptance was **conformant**: the endpoint contract shared by both bounds says verbatim that "Each endpoint is a number, a Date, or a string", and the empty string is a string. So this narrows a published face by adding a rule to it, rather than pulling code back to a declaration it was already violating.
14+
15+
What made the acceptance wrong is the other half of the same contract — "Closed interval [min, max]" — which no backend can honour against a blank. `driver-sql` binds the blank into `whereBetween`; the JS matchers compare it as a value. Either way the range stops bounding on that side **while still reading as a complete two-element range**, so the query runs with one meaningless boundary and no signal at any layer. The reference matcher was already taught to survive the `null` form of exactly this (a bounded range answered every valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed.
16+
17+
The only producer ever measured is a UI builder padding a **half-typed** pair so a length-based completeness check passes it. Nobody writes a blank bound on purpose — which is why it is refused rather than given a published meaning.
18+
19+
```
20+
FROM FieldOperatorsSchema.safeParse({ $between: [1, ''] })
21+
-> { success: true } // a half-filled range, green all the way
22+
// to the driver
23+
24+
TO FieldOperatorsSchema.safeParse({ $between: [1, ''] })
25+
-> { success: false,
26+
issues: [{ code: 'custom', path: ['$between', 1],
27+
message: 'A blank value is not a valid $between endpoint at index 1
28+
(the MAX bound). …' }] }
29+
```
30+
31+
## Migration — FROM → TO
32+
33+
| You wrote | Write instead |
34+
| --- | --- |
35+
| `{ $between: [1, ''] }` | `{ $between: [1, 100] }` — the upper bound you meant, written out |
36+
| `{ $between: ['', '2026-12-31'] }` | `{ $between: ['2026-01-01', '2026-12-31'] }` — the lower bound you meant |
37+
| a range that was only ever bounded on ONE side | `{ "$gte": min }` or `{ "$lte": max }` — a one-sided bound is not a range |
38+
39+
**The one-line fix: write the bound that is missing, or — if only one side was ever meant — drop `$between` and write that side as a scalar comparison.** ⛔ Not mechanically convertible: the bound the author did not type is not recoverable from the one they did, so this ships as an ADR-0087 D3 structured TODO and **no D2 conversion**. Both of the two readings a conversion could take are wrong — dropping the operator deletes a constraint the author wrote and silently WIDENS the result set, and treating the blank side as unbounded invents a filter nobody authored.
40+
41+
<!-- adr-0087: registered filter-between-blank-endpoint-refused -->
42+
43+
## What does NOT change
44+
45+
- **Arity.** A one-element or three-element `$between` was already refused, and still is, by the tuple's own contract. This rule is about a two-element range one of whose elements means nothing.
46+
- **`null` bounds.** Already refused since 2026-08-31, and they keep **their own** message, which prescribes the null predicate — an author who wrote `null` was reaching for absence, not for a bound. Two blank spellings, two intents, two remedies.
47+
- **Falsiness.** `{ $between: [0, 100] }` and `{ $between: ['0', '9'] }` parse exactly as before. The rule is blankness, not falsiness.
48+
- **Whitespace-only endpoints** are deliberately **not** judged. The ruling is the empty string; widening the refusal past it would narrow a published face further than the ruling did.
49+
- **The set slots.** `{ $in: ['', 'won'] }`, `{ $nin: [''] }`, `{ $eq: '' }` and `{ $gte: '' }` are untouched — an empty string is a legitimate stored VALUE, and only an interval ENDPOINT is judged here.
50+
- **Stored documents.** The read path does not re-validate stored rows, and the stored-row conversion pass neither validates nor drops anything, so no stored view becomes unreadable. What changes is that **re-saving** one is refused, at the endpoint's own path, with the blank side named.
51+
- **The published export surface.** No export is added, removed or renamed; the refusal rides the existing endpoint factory that both the documentation copy (`RangeOperatorSchema`) and the enforced copy (`FieldOperatorsSchema`) already share, so the two cannot drift.

0 commit comments

Comments
 (0)