Skip to content

Commit 4cc1b3b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20390-forward-conversion-window
# Conflicts: # packages/metadata-core/src/artifact-forward-conversion.test.ts
2 parents 9c92487 + 0283cb9 commit 4cc1b3b

132 files changed

Lines changed: 5980 additions & 469 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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/lint": minor
5+
"@objectstack/metadata-protocol": minor
6+
"@objectstack/metadata-core": patch
7+
---
8+
9+
feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429)
10+
11+
<!-- adr-0087: registered flow-decision-mode-inclusive-explicit -->
12+
13+
Clause-②: yes
14+
15+
**BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that
16+
declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose
17+
condition held, one after another, while its schema, the docs and the engine's own comment all
18+
called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in
19+
one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows
20+
BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every
21+
true branch is a declaration the author writes down.
22+
23+
| | before | after |
24+
|:--|:--|:--|
25+
| two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step |
26+
| `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially |
27+
| none holds | the `isDefault` edge runs | unchanged |
28+
| `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence |
29+
30+
## Migration: FROM → TO
31+
32+
`os migrate meta --from 17` lists the mechanical edits for existing sources and applies them
33+
to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit`
34+
writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more
35+
conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it
36+
did.
37+
38+
```ts
39+
// FROM — every true out-edge ran
40+
{ id: 'verdict', type: 'decision', label: 'Verdict?' }
41+
// TO — what the conversion writes; delete the key where the conditions partition
42+
{ id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } }
43+
```
44+
45+
Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match`
46+
carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside
47+
`!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on
48+
more than one branch running for one record, and where the overlap was accidental narrow the
49+
conditions into a partition and delete the key. `os validate` reports
50+
`flow-decision-inclusive-overlap` on every decision that keeps the key with two or more
51+
conditioned out-edges, so the review list is the lint output.
52+
53+
## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429
54+
55+
A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with
56+
**no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates
57+
**first-match** after this upgrade: where it took every out-edge whose condition held, it now takes
58+
only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row
59+
— no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row
60+
says it was saved before the flip. The one-line fix, for a node that meant every branch:
61+
62+
```ts
63+
{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } }
64+
```
65+
66+
`os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under
67+
`decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run
68+
alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an
69+
operator can review the candidates before and after the upgrade. A node leaves the list once it
70+
declares `mode`, either member. Every such node in the measured corpus below is a partition, where
71+
the new meaning runs exactly what the old one did.
72+
73+
Authored sources and built artifacts keep the old behaviour instead, where the source's age is a
74+
fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel,
75+
the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the
76+
conversion by id — a default flip replayed there would turn a decision written today against this
77+
contract, where an omitted `mode` means exclusive, into an inclusive gateway.
78+
79+
## Reach, measured at landing
80+
81+
- Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`,
82+
2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only —
83+
`mode` has not shipped; `.changeset/19867-decision-config-mode.md` and
84+
`.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this
85+
tree. So `mode` reaches its first release together with the traversal that reads it and the
86+
conversion that writes it; no published accept set narrows, and the registration and
87+
`os validate` refusals narrow nothing that shipped.
88+
- Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`,
89+
read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and
90+
no `mode` (the conversion's positives — every one a hand-written partition, including
91+
hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned
92+
out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the
93+
exclusive traversal is scoped to `decision` with nothing else to migrate.
94+
- What the published surface gains: the D2 conversion and its D3 entry in the protocol-18
95+
chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and
96+
docblock now state the run-time semantics, and `@objectstack/lint` gains
97+
`flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory).
98+
99+
The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node
100+
type keep the every-true-edge traversal they had (none was measured to exist).
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
An analytics cube's `public` now takes effect, and it defaults to visible: `CubeSchema.public` defaults to `true` (it was `false`), and the analytics service hides a cube that declares `public: false` from discovery and refuses every query against it (#20282).
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: registered analytics-cube-public-default-visible-enforced -->
11+
12+
**BREAKING**: this narrows what the analytics API answers. A query or SQL dry run against a cube declared `public: false` (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) was answered before this change and is now refused with `404 CUBE_NOT_FOUND`, and `GET /api/v1/analytics/meta` no longer lists that cube. The same happens to every cube in an artifact built by `os compile` before this release, which carries a materialized `public: false` from the old default. The remedy: delete `public: false` from any cube that is meant to be queried (cubes are visible by default), and recompile pre-release artifacts. It ships as `minor` under the launch-window convention; the widening half is the default moving to visible.
13+
14+
Until this change nothing read `public`. `GET /api/v1/analytics/meta` listed a `public: false` cube and every query door answered it, so the flag withheld nothing. Its declared default, `false`, could not simply be switched on: enforcing it as declared would have hidden every cube that omits the key. The default is now the Cube.dev default (visible), and an explicit `false` is enforced:
15+
16+
- `GET /api/v1/analytics/meta` omits a cube declared `public: false`, and `?cube=` naming one answers `[]`, the same as a name no cube has.
17+
- `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` refuse it with `404 CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so a caller cannot use it to learn that a hidden cube exists. The one shared message names both possibilities, so it still tells an author how to expose a hidden cube. The refusal comes before any SQL is built, and it is never an empty result.
18+
19+
`public` is visibility on the analytics API, not row security. An object's records stay governed by its permissions and row-level security on every door, whether or not a cube over it is hidden. What `public: false` does is exactly the two points above: the cube is left out of `/analytics/meta`, and queries and SQL generation against it are refused. The cube's definition stays readable on the metadata door, like any other authored schema.
20+
21+
What to expect after upgrading:
22+
23+
- **A cube that omits `public`** stays visible and queryable. It was visible before too, because nothing read the key. A client that parses cube metadata through the published JSON Schema now materializes `public: true` where it materialized `false`.
24+
- **A cube that writes `public: false`** is now hidden and refused. If you wrote it only because it was the old default, delete the line (cubes are visible by default). A dashboard or report that queries such a cube starts answering `404 CUBE_NOT_FOUND` until you do.
25+
- **A compiled artifact built before this release** carries a materialized `public: false` on every cube, because `os compile` writes the parsed stack with its defaults applied. Recompile it with this release before serving cubes from it.
26+
- **Cubes the platform mints itself** stay visible: the cube inferred for an ad-hoc query on an object (the KPI path), a compiled dataset's cube (`POST /api/v1/analytics/dataset/query`), and `CubeRegistry.inferFromObject`. Each wrote a literal `false`, the old default, and now writes `true`.
27+
28+
The showcase example's `showcase_delivery` cube, which is the app's demonstration of `/api/v1/analytics/*`, drops its `public: false`.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(spec)!: a flow node config its executor cannot run — a key its contract requires, left out, or a decision branch list it cannot read — is refused at authoring (#20316)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered flow-node-config-required-keys-refused -->
11+
12+
**BREAKING** — an accept-set narrowing on authored flow-node `config`, shipped as
13+
`minor` under the launch-window convention (`check-changeset-no-major` refuses
14+
`major` until GA; breaking-ness is carried by this banner and the ADR-0087
15+
disposition above, not by the level).
16+
17+
**What changed.** A flow node's `config` is an open record, so what its executor
18+
requires was checked by no build door. `FlowSchema.parse`, `AutomationEngine.registerFlow`
19+
and `objectstack validate` all admitted a node that left out a key its executor
20+
contract requires — and the executor's own contract parse then refused the node on
21+
every run that reached it. A `decision` branch with no `label` was worse: it never
22+
failed, the matched branch reported no label, and traversal took EVERY out-edge, so
23+
the flow ran green down the wrong paths. All three doors now refuse these shapes
24+
through one judge, `flowNodeConfigRefusals` (new in `@objectstack/spec/automation`):
25+
26+
- **A key a builtin's executor contract requires, left out.** Each builtin node's
27+
config is parsed against the very contract its executor parses against
28+
(`getBuiltinNodeConfigContracts()`, new, reconciled against the executors' own parse
29+
calls), and only the keys left out are kept — a present value of the wrong type, and
30+
an undeclared key, are judged where they were before. The keys: `objectName` on
31+
`get_record` / `create_record` / `update_record` / `delete_record`; `recipients` on
32+
`notify` (and `title` when there is no `template`); `url` on `http`; `function` on
33+
`script`; `flowName` on `subflow`; `collection` and `flowName` on `map`;
34+
`collection` on a `loop` that has a `body`; `branches` on `parallel`; `try` on
35+
`try_catch`; and on `screen`, each field's `name`, each option's `value` and
36+
`label`, and a `lookup` field's `reference`. A key a rule of the contract requires
37+
(the `notify` title, the `lookup` reference) is refused in the contract's own words.
38+
- **A `decision` branch list its executor cannot read.** `conditions` present and not
39+
`null` must be an array; every branch must be an object; every branch's `label` must
40+
be a non-blank string (absent, `null`, blank or non-text all name no out-edge).
41+
42+
Each refusal is a `custom` issue anchored at the key (`nodes.1.config.objectName`,
43+
`nodes.1.config.fields.0.name`, `nodes.1.config.conditions.0.label`, or the region
44+
path `nodes.1.config.body.nodes.0.config…`), met at `registerFlow` and
45+
`objectstack validate` through that same parse, and reported by
46+
`validateStackExpressions` for a stack handed to it directly. The refusal codes join
47+
`FLOW_SLOT_REFUSAL_CODES`: `node-config-key-missing`, `node-config-key-required-by-rule`,
48+
`decision-conditions-not-array`, `decision-branch-not-object`,
49+
`decision-branch-label-missing`.
50+
51+
The Studio flow designer writes refused shapes when a node is added and saved before
52+
it is configured, when a decision branch row's label cell is left empty, and when a
53+
screen field row's name cell is left empty. Where such a node already sits, the whole
54+
flow is refused: registered from the metadata registry or `sys_metadata` at boot, it
55+
is skipped with a `failed to register flow` warn naming it while the flows beside it
56+
register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the
57+
whole stack; an artifact file is refused whole at load.
58+
59+
## FROM → TO
60+
61+
| you wrote | write instead |
62+
|:--|:--|
63+
| `{ type: 'get_record', config: { outputVariable: 'rows' } }` | the object it reads — `config: { objectName: 'account', outputVariable: 'rows' }` (the same for `create_record` / `update_record` / `delete_record`) |
64+
| `{ type: 'loop', config: { body: { … } } }` | the array it iterates — `config: { collection: '{rows}', body: { … } }` |
65+
| `{ type: 'map', config: { flowName: 'per_row' } }` | `config: { collection: '{rows}', flowName: 'per_row' }` |
66+
| `{ type: 'http', config: { method: 'GET' } }` | `config: { url: 'https://api.example.com/v1/items', method: 'GET' }` |
67+
| `{ type: 'script' }` | the registered function it calls — `config: { function: 'recalc_totals' }` |
68+
| `{ type: 'notify', config: { recipients: ['{record.owner}'] } }` | a content source — `title: 'Deal won'`, or a `template` |
69+
| `conditions: [{ expression: 'record.amount > 1000' }]` on a `decision` | the out-edge it routes to — `[{ label: 'large', expression: 'record.amount > 1000' }]`, beside an out-edge labelled `large` |
70+
| `conditions: ['record.amount > 1000']` | `[{ label: 'large', expression: 'record.amount > 1000' }]` |
71+
72+
**One-line fix:** write the key the node was meant to carry. To branch on the
73+
out-edges instead of on `conditions`, delete `conditions` and put each predicate on its
74+
edge's `condition`.
75+
76+
**Unchanged.** A node carrying every key its contract requires parses, registers and
77+
validates as before; a legacy flat-graph `loop` (no `body`) still needs no
78+
`collection`; a `decision` with no `conditions`, `conditions: null` or an empty list
79+
still routes by its out-edges; `assignment`, `wait`, `connector_action` and plugin node
80+
types are not judged by this rule; and a key spelled by a D2 alias (`object`, `flow`,
81+
`functionName`, …) is still canonicalized before `registerFlow` and `objectstack
82+
validate` judge it.

‎.claude/skills/pm-dispatch/references/lanes/ui.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
## 形态
66

77
- 本席为姊妹仓执行座位:在 objectui 仓认领、派发、复核、落地。
8-
- ⛔ 不产 `domain:*`、type 与定级,中央分诊唯一生产,见 SKILL.md 多仓协调规则 1 与 4。
9-
- 误标 ⛔ 不自改:挂 `pm:retriage` 加异议评论同笔,见同节规则 4。
8+
- ⛔ 不产 `domain:*`、type 与定级,中央分诊唯一生产;误标 ⛔ 不自改,挂 `pm:retriage`+异议同笔。
9+
- 维护者裁决例外于上条与 SKILL.md 无标签禁认领:main/队列 `Spec Main Shape Gate` 红即止血认领。
1010

1111
## 范围
1212

‎content/docs/api/client-sdk.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ const result = await client.analytics.query({
318318
});
319319
console.log(result.rows.length, result.fields[0].name); // AnalyticsResult, unwrapped
320320

321-
// Get cube metadata — all cubes, or one with meta('account')
321+
// Get cube metadata — all cubes (those declared public: false are omitted), or one with meta('account')
322322
const meta = await client.analytics.meta('account');
323323
console.log(meta[0].name, meta[0].measures.length); // the bare cube list
324324

‎content/docs/api/data-api.mdx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -479,10 +479,12 @@ so its column shows the tenant default. The cube query on this page carries no c
479479

480480
### `GET /analytics/meta`
481481

482-
Get metadata for all registered cubes. Cubes are explicitly defined (via `defineCube`
483-
or the analytics service's `cubes` config) — a cube referenced by a query that isn't
484-
yet registered is lazily auto-inferred from that query's shape, but metadata isn't
485-
proactively generated for every object.
482+
Get metadata for all registered cubes, except those declared `public: false`, which are
483+
omitted (and refused by `POST /analytics/query` and `POST /analytics/sql` with the same
484+
`404 CUBE_NOT_FOUND` an unknown cube name gets). Cubes are explicitly defined (via
485+
`defineCube` or the analytics service's `cubes` config) — a cube referenced by a query
486+
that isn't yet registered is lazily auto-inferred from that query's shape, but metadata
487+
isn't proactively generated for every object.
486488

487489
Pass `?cube=<name>` to filter the listing to a single cube (this is what
488490
`client.analytics.meta(cube)` sends).

0 commit comments

Comments
 (0)