Skip to content

Commit e6879a8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15196-s5b-grant-readers-by-name
2 parents 4812859 + 5ff7cbe commit e6879a8

60 files changed

Lines changed: 4165 additions & 339 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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': patch
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration.
8+
9+
Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door)
10+
11+
<!-- adr-0087: registered flow-builtin-node-config-undeclared-keys-refused -->
12+
13+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
14+
15+
**Why.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered.
16+
17+
**What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.<key>`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges.
18+
19+
**`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text.
20+
21+
**`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate.
22+
23+
**What stays as it was.**
24+
25+
- `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration.
26+
- Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions.
27+
- A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before.
28+
- A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged.
29+
- `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged.
30+
- A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key.
31+
32+
## FROM → TO
33+
34+
| you wrote | write instead |
35+
|:--|:--|
36+
| a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names |
37+
| `fieldValues` on `create_record` / `update_record` | `fields` |
38+
| `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` |
39+
| `visibleIf` on a screen field | `visibleWhen` |
40+
| `itemVariable` on a `loop` | `iteratorVariable` |
41+
| `outputVariable` on an `http` node | delete it: the http executor binds no output variable |
42+
| a key nothing reads | delete it |
43+
44+
**The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does.
45+
46+
**Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured.
47+
48+
### The kit
49+
50+
- **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`.
51+
- **The ledger.** The D3 semantic entry `flow-builtin-node-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
A first boot no longer logs a refused `sys_migration` insert, and the owner-bind ledger row records the outcome that happened
6+
7+
Clause-②: no
8+
9+
On every first boot (`os serve` and `os dev`, on `:memory:`, file SQLite and PostgreSQL), *Boot diagnostics* showed `WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}` with a full stack. The one-time default-organization owner bind (`createEnsureDefaultOrganizationOnce`, ADR-0093 D7) re-entered itself: the owner write makes the security plugin grant `organization_admin`, that grant insert is a bootstrap trigger, and the inner call recorded the decision first. It recorded `admin-already-member` with no organization. The outer call's accurate record (`promoted` or `bound`, with the organization id) was then refused. Nothing was bound twice. The ledger row named the wrong outcome.
10+
11+
The gate now marks the decision in flight before its first `await`. A call that finds it in flight still creates a missing Default Organization, but binds nobody and records nothing. The deciding call records its own outcome once. A call that did not act (no platform admin yet, or a refused write) clears the mark, so the next trigger still decides.
12+
13+
- **Nothing you author changes.** No key, export or option is added or removed. The walled `@objectstack/organizations` wiring calls the same gate and gets the same rule.
14+
- **A real ledger write failure is still reported.** Only the gate's own second write is gone; a refused `sys_migration` insert for any other reason still logs at `error`, with the remedy.
15+
- **Rows already written are not rewritten.** A deployment whose first boot recorded `admin-already-member` keeps that row; nothing reads its `details`.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): a stopped `ObjectKernel` removes its SIGINT/SIGTERM/SIGQUIT listeners and never exits the process
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** With `gracefulShutdown: true` (the default) the constructor added one listener per signal and kept no handle, so `shutdown()` never removed them. Every kernel a process built left three listeners behind, and each one called `process.exit(0)` on the next signal. A process that had built and stopped five kernels and still ran a sixth exited six times on one SIGTERM, and the first exit landed before the live kernel had drained. Eleven built-and-stopped kernels also tripped Node's `MaxListenersExceededWarning`.
10+
- **What a kernel does now.** It holds its listeners from construction until it reaches `stopped`, and removes them at that point, whichever way it got there: `shutdown()` completing, the shutdown timeout, or a failed `bootstrap()`. A stopped kernel has no listener, so it cannot handle a signal. While a kernel is `stopping` its listener stays installed, so a repeated signal is absorbed and does not trigger Node's default action mid-drain.
11+
- **Who exits the process.** Only the handler whose signal started the drain, and only once. A signal that arrives while the host is already stopping the kernel through `shutdown()` is logged and left to the host. The kernel no longer exits the process in that case.
12+
- **Unchanged.** A running kernel still drains and exits with code 0 on SIGINT, SIGTERM or SIGQUIT (code 1 if `shutdown()` throws). `gracefulShutdown: false` still installs nothing. A genuine shutdown timeout still calls `process.exit(1)`.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate meta` runs on a project built with `composeStacks`, and converts the retired spellings inside its package bodies (#22289)
6+
7+
Clause-②: no
8+
9+
- **What changed.** On a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, `os migrate meta --from N` used to exit 1 with `STACK_PROVENANCE_MISSING`, telling the author to wrap each input in `defineStack`, as soon as any input carried a spelling the current schema refuses. Every input already was wrapped. The command now loads such a project: an input whose `defineStack` call the current schema refuses is handed to `composeStacks` as `defineStack(input, { strict: false })` returns it, and composition runs as it does at build time.
10+
- **The package bodies are migrated.** A composed artifact keeps each definition under the package that owns it (`packages[i].manifest`), and the migration chain used to reach only the stack's own top level. It now also runs over each package body, and lists each change under the body's path, such as `packages[0].manifest.objects[0].fields.starts_at.defaultValue`. `--write` writes a change in package i into the file that authored input i only when input i and every input before it in the `composeStacks([…])` list is a stack literal (a `defineStack({ … })` call on an object literal, written in place or reached through a `const` or an import) that writes its own `manifest` and no `packages`. Only then is package i that one input's body. Otherwise `--write` lists the change with the reason, as it does for any site it cannot trace.
11+
- **What did not change.** A one-package project loads, migrates and writes exactly as before. An input that was never built by `defineStack` (a plain object, or a spread of a built stack) is still refused with `STACK_PROVENANCE_MISSING`.
12+
- **Known limit.** Inside a composed project, a conversion the load still applies (for example `datasources[].driver: 'mongo'`) is applied while the input is composed. So the chain does not list it and `--write` does not write it, the same as for an input the current schema accepts.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
---
4+
5+
fix(service-storage)!: a chunked upload completes with the file it was sent — the completion door assembles the parts the upload holds, and a re-sent chunk is counted once
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime upload door: a chunked completion whose upload does not hold its declared bytes, which used to answer 200 with a short or empty file, is refused with 409 before anything is assembled. The other categories are closed on facts: the one bumped package publishes (not unpublished); no ADR-0087 id covers these paths and this diff adds none (not registered / already-registered); and nothing exported is removed or narrowed — the diff changes no export, type or route option — so it is neither runtime-interface-only nor type-surface-only. -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
**What was wrong.** `POST /storage/upload/chunked/:uploadId/complete` assembled the parts the *request* listed. The SDK's `resumeUpload` lists only the chunks its own pass sent, so an upload whose first chunks were stored before an interruption completed with `200` and the declared `size`, while the stored file held only the resumed chunks. And `PUT …/chunk/:chunkIndex` added a re-sent chunk to the session's progress a second time, so `GET …/progress` overstated `uploadedSize`, `uploadedChunks` and `percentComplete` — the counts `resumeUpload` resumes from.
14+
15+
**What the completion door does now.** It assembles the upload's own record of the chunks it holds — every chunk the chunk door stored, with the eTag the backend answered for it — and checks the request's `parts` against that record. A resumed upload therefore completes with the whole file, whatever subset of parts the client lists, and the SDK's `resumeUpload` needs no change.
16+
17+
**What stops being accepted.** The door refuses with `409` and the standard code `RESOURCE_CONFLICT`, before anything is assembled, when:
18+
19+
- the upload does not hold every chunk it declared (chunk indexes `0` to `totalChunks - 1`), or holds one beyond them;
20+
- the chunks it holds do not add up to the declared `totalSize`;
21+
- the request lists a chunk the upload does not hold, or lists one with an eTag other than the one the upload holds for it.
22+
23+
`error.details` carries `missingChunks`, `unexpectedChunks`, `unheldListedChunks` and `mismatchedChunks` (zero-based chunk indexes), with `totalChunks`, `totalSize` and `heldBytes`. The session stays `in_progress` and the file stays `pending`: upload the missing chunks with `PUT …/chunk/:chunkIndex`, then complete again. A `parts` value that is not a list of `{ chunkIndex, eTag }` is refused `400 INVALID_REQUEST`; an absent `parts` is still an empty list.
24+
25+
**What changes for you.** A client that completed an upload before sending all of its bytes — including a completion with no chunks at all, which used to commit an empty file — now receives the `409` above. Send every chunk first.
26+
27+
**Progress.** A chunk index sent again replaces its slot, as it replaces its bytes in the backend: `uploadedChunks` counts distinct chunks and `uploadedSize` is the sum of each chunk's latest size. A session started before chunk sizes were recorded keeps its running byte total until it ends, and is completed only when that total reaches `totalSize`.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
---
4+
5+
`AuditPluginOptions` gains `getLocale`, an optional host locale resolver asked before the settings-derived locale, so a multi-organization host can write each organization's activity summaries in its language
6+
7+
Clause-②: yes (widening)
8+
9+
- **The option.** `new AuditPlugin({ getLocale })`, where `getLocale` is `(tenantId?: string, userId?: string) => string | undefined | Promise<string | undefined>`. It picks the language of `sys_activity.summary` and of the @mention notification title the plugin emits, and is called with the write's organization and user (for an @mention title, the mentioned recipient).
10+
- **Precedence: host first, settings second.** An answer that is a well-formed BCP-47 tag is used, in its canonical form (`zh-cn` → `zh-CN`). `undefined`, `null` or a blank string falls back to the settings-derived `localization.locale` (ADR-0053) the plugin used before this option. So do a malformed answer (`zh_CN`, a non-string) and a throw or rejected promise; each of those two faults is logged once at `warn`, and neither fails the audited write. There is no other locale source.
11+
- **Without the option, nothing changes.** The settings-derived locale stays the only source, as before.
12+
- The locale only picks the message catalog, and a summary is rendered once, when its row is written. Rows already written keep their text.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
The auth plugin registers its `app:seeded` membership-backfill handler when its backfill is armed, not when the plugin starts
6+
7+
Clause-②: no
8+
9+
The one-time membership backfill (ADR-0093 D6) re-runs on `app:seeded`, so users written by a seed that settles after `kernel:ready` still get a membership. Its handler used to be registered in `start()` and kept from acting early by a runtime flag: until the backfill's own `kernel:ready` hook armed it, the handler returned without doing anything. The handler is now registered by that `kernel:ready` hook, at the moment it arms the backfill, so it does not exist before the settings engine binds. The boot-ordering gate reads this from the source, which it cannot do with a flag.
10+
11+
- **What the backfill does is unchanged.** An `app:seeded` that fires before the backfill is armed still does nothing, and one that fires after it still re-runs the pass.
12+
- **No option, setting or export changes.** `OS_SKIP_MEMBERSHIP_BACKFILL=1` still registers no `app:seeded` handler at all.

0 commit comments

Comments
 (0)