Skip to content

Commit ea78da8

Browse files
committed
Merge main into claude/issue-19665-iso-pin-stable-keys
Base merge onto main 2c1011b to re-measure the head. The pin file is byte-identical on the merge base and on main, so it merges untouched. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
2 parents fc8eda2 + 2c1011b commit ea78da8

91 files changed

Lines changed: 7166 additions & 731 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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
'@objectstack/plugin-webhooks': minor
3+
---
4+
5+
fix(plugin-webhooks): a webhook credential stored as cleartext inside `sys_webhook.definition_json` is refused, at the delivery path and at the write door (#10164)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner and the ADR-0087 disposition below, never by the level).
12+
13+
**Webhooks that still carry the legacy cleartext shape STOP DELIVERING.** A
14+
`sys_webhook` row whose signing secret or custom header map exists only as a
15+
`secret` / `headers` key inside `definition_json` — with nothing stored in the
16+
encrypted `signing_secret` / `headers_secret` column — used to be delivered from
17+
that cleartext with a `warn`. It is now refused, dated `2026-09-23`:
18+
19+
- **Delivery path.** The subscription is PARKED, the same fail-closed shape as an
20+
encrypted credential that cannot be recovered: nothing is sent, and every
21+
matching record change is recorded in `sys_http_delivery` as a `dead` row with
22+
0 attempts, no signature and no headers. Its `error` names the refusal as
23+
`[VALIDATION_ERROR/400]`, and the drop is reported once at `error`, with
24+
`code: 'VALIDATION_ERROR'`, `status: 400`, `field: 'definition_json'` and the
25+
refused `keys` in the log meta.
26+
- **Write door** (when `WebhookOutboxPlugin` is mounted, the standard mount). A
27+
`sys_webhook` insert or update whose `definition_json` carries a `secret` or
28+
`headers` key, whatever its value, is refused before anything is stored
29+
(`VALIDATION_ERROR` / `400`, with `object`, `field` and `keys` on the error).
30+
That covers a raw `PATCH /api/v1/data/sys_webhook`. It also covers a Setup-form
31+
save that echoes back a legacy blob unchanged. Omitting `definition_json`, or
32+
writing one without those keys, is unaffected. The plugin binds this refusal
33+
itself, and it is not exported from the package entry: a host that composes
34+
`AutoEnqueuer` on its own still gets the delivery-path refusal above, but not
35+
this write-door refusal.
36+
37+
**Fix.** Both remedies need a registered `CryptoProvider`
38+
(`engine.setCryptoProvider` — `LocalCryptoProvider` in dev, KMS/Vault in
39+
production), because writing a `secret`-typed column is itself refused without
40+
one. With a provider registered, either:
41+
42+
- restart, and the boot sweep `migrateLegacyWebhookSecrets` moves both values into
43+
their encrypted columns and strips them from `definition_json` in one update. The
44+
subscription re-arms at the next refresh.
45+
- or re-author the webhook yourself. Write the key into `signing_secret` and the
46+
header map into `headers_secret` (a JSON object of string values), then remove
47+
both keys from `definition_json`.
48+
49+
There is no transition path for a deployment that runs with no `CryptoProvider`.
50+
51+
Unchanged: authoring. `defineWebhook({ secret, headers })` is written exactly as
52+
before, and the boot materializer still routes each value to its encrypted column.
53+
A row whose encrypted columns are set delivers exactly as before.
54+
55+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched, `WebhookSchema` still declares `secret` and `headers`, and the materializer still routes them to their encrypted columns, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What is refused is a stored DATA-row shape on `sys_webhook`, and its converter already ships and runs at every boot: `migrateLegacyWebhookSecrets`, named in the refusal and in the Fix paragraph above. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a data-row sweep (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses a fixed-shape config object both objects declare with different values (#16075)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed: every object key, every `composeStacks` option and the `ConflictStrategySchema` enum parse exactly as before, so `objectstack migrate meta` has nothing to rewrite. What narrows is the ACCEPT SET of one option value at composition time, one step past the #14848 narrowing that answered the same question the same way: two stacks whose same-name objects both declare a fixed-shape config object (`enable`, `access`, `protection`, ...) with different values are now refused under `'merge'` where they used to compose with the earlier declaration silently replaced. The refusal text names the object, the key and both stacks and carries its own fix, no stored metadata row or authored file changes shape, and the repository measures zero non-test call sites passing `objectConflict` at all, so there is no document for a migration to act on. -->
8+
9+
**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })`
10+
— shipped as `minor` under the repo's launch-window convention for breaking
11+
changes. Maintainer ruling on #16075 (ruling record 5563452716, director
12+
decision batch #61, option 1, verbatim 「同意」): the #14848 refusal extends to
13+
fixed-shape config objects.
14+
15+
**What changed.** #14848 made `'merge'` refuse every object-level
16+
**collection** two stacks declare differently, and left everything else on
17+
later-wins. "Everything else" included eight **fixed-shape config objects** on
18+
`ObjectSchema` — `userActions`, `external`, `tenancy`, `access`, `lifecycle`,
19+
`enable`, `publicSharing`, `protection`. Measured on `main` @ `44ce049a8`
20+
before this change, each of the eight composed to the LATER object's
21+
declaration wholesale, with nothing said: `enable: { trackHistory: true }`
22+
beside `enable: { apiEnabled: true }` lost `trackHistory`, and an add-on
23+
package's `access: { default: 'public' }` switched a core package's
24+
`access: { default: 'private' }` off — the posture downgrade `composeStacks`
25+
already refuses at the top level for `api` / `server`.
26+
27+
Now, when both objects declare one of them with different values,
28+
`composeStacks` throws the refusal it throws for a collection — same code
29+
(`STACK_COMPOSE_COLLECTION_CONFLICT`), same `status: 422`, same three-line
30+
shape — naming the object, the key and both stacks by manifest id:
31+
32+
```
33+
composeStacks conflict: object 'shared' is defined in multiple stacks and its 'access' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1).
34+
objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged, and neither is a fixed-shape config object (userActions, external, tenancy, access, lifecycle, enable, publicSharing, protection): the later declaration would replace the earlier one wholesale, silently dropping every member 'com.example.a' (stack #0) set.
35+
Fix: declare 'access' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack.
36+
```
37+
38+
The config-object half of the refusal set is **derived from `ObjectSchema`'s
39+
shape**, like the collection half — every key whose declared type, through
40+
optional/default wrappers, a `lazy` or a `pipe`'s authored side, is a plain
41+
object and not a collection — so a config object added to the object schema
42+
joins the refusal without an edit to the composer. The collection refusal's
43+
message now lists both kinds; its first and last lines are unchanged.
44+
45+
**What did not change.**
46+
47+
- `fields` keeps its documented shallow merge (later fields win, earlier
48+
fields kept).
49+
- **Identical** declarations on both sides pass through and are carried once
50+
— the reading `'merge'` already gives an identical collection. Because the
51+
strict parse fills a config object's member defaults, "identical" is judged
52+
on the parsed objects: `enable: { apiEnabled: true }` and
53+
`enable: { apiEnabled: true, trackHistory: false }` are the same declaration.
54+
- A config object only the earlier object declares is kept; a later object
55+
that does not declare it (or declares it `undefined`) leaves it in place.
56+
- A **scalar** the later object declares (`label`, `sharingModel`, …) still
57+
replaces the earlier one. So does a key whose type is a **union** admitting
58+
an object beside a non-object form — `systemFields` (`false` or an options
59+
object) and `titleFormat` (a template string or an expression object): a
60+
union is not a fixed shape, and the ruling covers the fixed-shape keys only.
61+
- The default `'error'` and `'override'` are untouched, message for message.
62+
63+
**Who is affected.** Measured on `origin/main` @ `44ce049a8`: **zero**
64+
non-test call sites in `packages/**`, `examples/**`, `apps/**` pass
65+
`objectConflict` at all — the one non-test `composeStacks` call
66+
(`examples/app-multi-package`) passes `{ manifest: 'preserve' }` and takes the
67+
default `'error'`. An external author who opted into `'merge'` and relied on
68+
the later package's config object winning silently now gets the refusal above;
69+
the fix is the one it names.
70+
71+
Clause-②: yes
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/lint': minor
5+
---
6+
7+
fix(spec)!: a blank string in a flow node's predicate slot — a `decision` branch `expression`, a screen field `visibleWhen` — is refused at authoring (#17493)
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: registered flow-predicate-slot-blank-string-refused -->
12+
13+
**BREAKING** — an accept-set narrowing on two authored flow-node slots, shipped as
14+
`minor` under the launch-window convention (`check-changeset-no-major` refuses
15+
`major` until GA; breaking-ness is carried by this banner and the ADR-0087
16+
disposition above, not by the level).
17+
18+
**What changed.** A `decision` node's `config.conditions[].expression` and a
19+
`screen` node's `config.fields[].visibleWhen` are declared bare CEL text. A string
20+
that is blank after trimming (`''`, `' '`, a tab or a newline) used to be
21+
accepted there by `FlowSchema.parse`, `AutomationEngine.registerFlow` and
22+
`objectstack validate`, and was then read as "no predicate": the evaluator answers
23+
a blank decision predicate `false`, so that branch was not taken, and nothing said
24+
so. It is now refused at those doors — by `FlowSchema.parse` with a `custom` issue
25+
anchored at the slot (for example `nodes.1.config.conditions.0.expression`), and
26+
by `registerFlow` and `objectstack validate` through that same parse — with a
27+
message that leads with the published `PREDICATE_SLOT_STRING_REFUSAL` sentence,
28+
the one these slots already answered with for a non-string value. Where such a
29+
value already sits, the whole flow is refused: registered from the metadata
30+
registry or `sys_metadata` at boot, it is skipped with a
31+
`failed to register flow` warn naming it while the flows beside it register; a
32+
`defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole
33+
stack; an artifact file is refused whole at load.
34+
35+
## FROM → TO
36+
37+
| you wrote | write instead |
38+
|:--|:--|
39+
| `conditions: [{ label: 'high', expression: ' ' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` — or, to keep what the blank did, `expression: 'false'` |
40+
| `fields: [{ name: 'reason', visibleWhen: '' }]` on a `screen` node | the predicate you meant — `visibleWhen: "status == 'rejected'"` — or, to keep what the blank did, drop the `visibleWhen` key |
41+
42+
**One-line fix:** write the predicate, or keep what the blank did — `'false'` on
43+
a decision branch (the value the blank evaluated to), no `visibleWhen` on a
44+
screen field (a blank one was read as absent). ⚠️ Do not drop a decision's only
45+
branch: the node then routes by its out-edges alone, and the out-edge that branch
46+
labelled is no longer held back. A blank structural `condition` is another case —
47+
see the `flow-edge-condition-evaluated-slot-source-required` migration entry.
48+
49+
**Unchanged.** A non-blank predicate parses, registers and validates as before;
50+
a non-string in these slots keeps its existing refusal at `registerFlow` and
51+
`objectstack validate`; `edges[].condition` and a node's `config.condition` keep
52+
their own rule and sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`); and
53+
`AutomationEngine.evaluateCondition` still answers a blank predicate `false` for
54+
a caller that reaches it directly. The `PREDICATE_SLOT_STRING_REFUSAL` constant
55+
keeps its name and now also names the blank string, so code matching the
56+
constant rather than a copy of its text is unaffected.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
The row properties of the `view.columns`, `view.tabs` and `view.sort` repeaters carry a JSON Schema `title`, so Studio's property panel stops printing raw machine keys as the column headers of those three tables (#17507).
6+
7+
Clause-②: no
8+
9+
Studio renders a `type: 'repeater'` form field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived from the metadata type schema. None of the 25 row properties of the three view repeaters carried a `title`, so the fallback arm ran and the maker saw `field` / `width` / `isDefault` / `order` in every locale, English included.
10+
11+
- **`view.columns`** — the 14 row properties of `ListColumnSchema` (`Field`, `Label`, `Width (px)`, `Alignment`, `Hidden`, `Sortable`, `Resizable`, `Wrap Text`, `Renderer Type`, `Pinned`, `Summary`, `Prefix`, `Primary Link`, `Click Action`).
12+
- **`view.tabs`** — the 9 row properties of `ViewTabSchema` (`Name`, `Label`, `Icon`, `List View`, `Filter`, `Display Order`, `Pinned`, `Default Tab`, `Visible`).
13+
- **`view.sort`** — the list view's INLINE `{ field, order }` sort entry (`Field`, `Direction`). It is not the shared `SortItemSchema`, so titling that schema never reached this table; the titles mirror it.
14+
- **Nothing the schema accepts or refuses moved.** `.meta({ title })` is presentation metadata: the generated `authorable-surface/` artifacts are byte-identical. The repeater-title ledger loses its last three entries and is now empty, so every repeater a form declares is fully titled and a new untitled one fails its own PR.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
JSON Schemas converted from a `lazySchema()` reference now carry the `description` the schema authored, the same as an `OS_EAGER_SCHEMAS=1` run (#19101).
6+
7+
Clause-②: no
8+
9+
zod reads `.describe()` / `.meta()` from its registry by node identity. A lazily built schema is referenced through a Proxy, while the metadata sits on the real instance behind it, so `z.toJSONSchema` found nothing and dropped the text. The published result depended on the evaluation mode. The Proxy now answers the real instance's metadata to that lookup, less `id`, which stays on the real instance so that zod's duplicate-id refusal is never triggered.
10+
11+
What changes: descriptions reappear. Nothing else does. Measured lazy against eager, leaf by leaf:
12+
13+
- `@objectstack/spec/openapi.json`, and the `GET …/openapi.json` document served from it, gains 2 (`ListRecordResponse.data[]` and `BulkRequest.records[]`);
14+
- the `os generate` IDE schema gains 445;
15+
- the approval-node and schemaless node-config schemas are unchanged.
16+
17+
No other key differs in any of them, and the eager outputs are byte-identical before and after. The accept set does not change: `description` is an annotation, never a validation keyword.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING** — `PackageApiContracts` loses its three entries that named routes nothing serves: `upgradePackage`, `resolveDependencies` and `uploadArtifact` (#19116).
6+
7+
A `major`-class change, recorded as `minor` under the launch-window convention. Maintainer ruling 2026-09-23, director seat decision batch #217 item 4, letter A, 「217 同意」; ADR-0049 enforce-or-remove.
8+
9+
**Why.** Each entry bound a path the composed runtime mounts nowhere — `POST /api/v1/packages/upgrade`, `POST /api/v1/packages/resolve-dependencies` and `POST /api/v1/packages/upload`. The package dispatcher has no route for any of them and `@objectstack/rest` mounts only `/packages/publish` under `/packages`, so a request to any of the three was never answered, while the generated API reference printed all three as live endpoints. Unlike `installPackage`, which was rebound onto the serving `POST /api/v1/packages`, there was no serving door to rebind these onto, and mounting three new capabilities nobody has asked for was ruled out.
10+
11+
### FROM → TO
12+
13+
| removed | what to write instead |
14+
| --- | --- |
15+
| `PackageApiContracts.upgradePackage` (`POST /api/v1/packages/upgrade`) | nothing — delete the read and any URL built from it. No route serves a package upgrade. |
16+
| `PackageApiContracts.resolveDependencies` (`POST /api/v1/packages/resolve-dependencies`) | nothing — delete the read and any URL built from it. No route serves dependency resolution. |
17+
| `PackageApiContracts.uploadArtifact` (`POST /api/v1/packages/upload`) | nothing — delete the read and any URL built from it. No route serves an artifact upload. |
18+
19+
**The one-line fix: delete every read of the three keys, and every request to the three paths.** The compiler finds the reads (`TS2339: Property 'upgradePackage' does not exist`); a hard-coded path has to be searched for. No behaviour is lost — none of those requests was ever answered.
20+
21+
**What stays.** The four entries whose doors serve — `listPackages`, `getPackage`, `installPackage`, `uninstallPackage` — are unchanged. The per-route request/response schemas (`PackageUpgradeRequestSchema`, `PackageUpgradeResponseSchema`, `ResolveDependenciesRequestSchema`, `ResolveDependenciesResponseSchema`, `UploadArtifactRequestSchema`, `UploadArtifactResponseSchema`, with their types) stay published, now bound to no route; their docblocks no longer name a route. If the platform later serves a package upgrade, dependency-resolution or upload route, its contract entry is declared in the same change that mounts it.
22+
23+
⚠️ Runtime behaviour is deliberately **unchanged**: nothing ever mounted the three paths or built a route, client or SDK method from the entries, so every request answers exactly as before. The removal retracts a false claim, not a capability. **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」).
24+
25+
⚠️ **The out-of-repo consumer population is NOT MEASURED.** Inside this repository the three paths occurred only in the declaring file, its unit test and the generated reference page, and the pinned objectui checkout names none of the keys, none of the paths and not `PackageApiContracts`; `@objectstack/spec` is published, so readers elsewhere were not measured.
26+
27+
The ADR-0087 D3 semantic entry `package-api-contracts-unmounted-entries-retired` carries the judgement: a contract-map entry is not metadata, so there is no source for a D2 conversion to rewrite.
28+
29+
Clause-②: no
30+
31+
<!-- adr-0087: registered package-api-contracts-unmounted-entries-retired -->

0 commit comments

Comments
 (0)