Skip to content

Commit 19a9b61

Browse files
committed
Merge origin/main into fix/3490-webhook-liveness-flip
Resolve conflicts in webhook.json + lint-liveness-properties.test.ts against #3494 (feat(spec)!: prune still-dead aspirational config from Webhook), which deleted body/payloadFields/includeSession/retryPolicy/tags/authentication from WebhookSchema. With those gone and the #3489 bridge making the rest live, the webhook ledger is 11 props ALL live — no dead surface, so no authorWarn carrier — and the lint test asserts an authored webhook is silent (the old `url` carrier is now live). proof-registry (23) / lint (14) / liveness gate (webhook 11 live) / webhook-materialization dogfood proof all green post-merge.
2 parents dde1a5c + d1cabaa commit 19a9b61

93 files changed

Lines changed: 5716 additions & 1491 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/service-storage": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
feat(storage): exclusive field-reference file ownership — ADR-0104 D3 wave 2 (PR-3)
7+
8+
A `file`/`image`/`avatar`/`video`/`audio` field that holds a `sys_file` id now
9+
records its owner on the file: `sys_file.ref_object` / `ref_id` / `ref_field`
10+
name the single `(object, record, field)` slot that references it, maintained on
11+
the engine write path — claimed on insert, reconciled on update, released when
12+
the owning record is deleted.
13+
14+
**Field references are exclusive, unlike attachments.** The attachments surface
15+
deliberately shares one file across many `sys_attachment` join rows; a field
16+
reference is owned by at most one slot, and writing an already-owned id into a
17+
second slot **copies the bytes into a fresh `sys_file`** rather than sharing the
18+
row. That keeps a file's read authorisation derived from exactly one parent
19+
record instead of the union of every referrer's — so copying a private record's
20+
file id into a world-readable one cannot silently widen access — and it removes
21+
reference counting from the lifecycle entirely: a file is released because its
22+
one owner let go, never because a count came back zero.
23+
24+
**Deletes nothing.** This records and releases ownership; it never tombstones,
25+
and the `scope === 'attachments'` guardrail that keeps field-referenced files
26+
out of the reap is untouched. Collection is a separate, gated change that must
27+
also extend the reap guard's sweep-time re-verify in the same commit.
28+
29+
Also exports `isFileIdToken` from `@objectstack/spec/data` as the single arbiter
30+
of "is this stored string an opaque file id, or a legacy/external URL?", now
31+
shared by the read resolver and the write claimer so the two cannot drift.
32+
33+
Dormant until a field actually holds an id token: objects without file-class
34+
fields, inline-blob values and URL-shaped values all exit before any I/O.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/service-storage": minor
3+
---
4+
5+
feat(storage): governed download for field-owned files — ADR-0104 D3 wave 2 (PR-4)
6+
7+
A file owned by a record's field (`sys_file.ref_object` / `ref_id`, set by
8+
PR-3) is now authorized on download the same way an attachment is: the caller
9+
must be able to READ the file's parent record, or be its uploader. Previously
10+
only `attachments`-scope files were gated and every field file kept an
11+
anonymous capability URL.
12+
13+
**Parent resolution differs by surface, and that asymmetry is the point.** An
14+
attachment may hang off many records, so its readable-by set is the union over
15+
its `sys_attachment` join rows. A field-owned file belongs to exactly one
16+
record, so its readable-by set is that one record's — nothing more. Under a
17+
shared reference model the field case would have had to union too, which is
18+
what makes copying a file id into a more public record silently widen access.
19+
20+
Denials are reported as `FILE_DOWNLOAD_DENIED` (403), distinct from the
21+
attachments path's `ATTACHMENT_DOWNLOAD_DENIED`, since the file *belongs to* one
22+
record rather than being *attached to* several.
23+
24+
**`acl: 'public_read'` is the opt-out**, and now an explicit declaration rather
25+
than the silent default every field file used to get. Genuinely public images —
26+
anything embedded in an `<img src>`, which cannot carry a bearer token — must
27+
declare it.
28+
29+
**Dual-mode safe, gates nothing that is open today.** A pre-cutover field holds
30+
an inline blob or an external URL, never a `sys_file` id, so no existing file
31+
has an owner recorded and none of them start being gated. The gate engages only
32+
for files a record's field has actually claimed, and disengages again when
33+
ownership is released.
34+
35+
---
36+
37+
Also adds `verifyFileReferences()` — the executable form of ADR-0104's R4
38+
acceptance gate. It compares ground truth (what records' file fields actually
39+
hold) against recorded ownership, and classifies disagreements by whether they
40+
could cause data loss once collection is enabled:
41+
42+
- **blocking**`unowned_reference` (a held file nothing owns), `foreign_owner`
43+
(a record holds a file owned by another slot), `shared_reference` (one file
44+
held by two slots, i.e. exclusivity was violated). Each would let a later reap
45+
delete bytes a record still points at.
46+
- **advisory**`stale_owner` (owned but no longer held; fails toward
47+
retention) and `unreferenced_file` (storage cost, not a correctness problem).
48+
49+
The scan is read-only — it never writes, tombstones, or deletes. A ledger may
50+
not be given authority over irreversible deletes until it has been shown to
51+
agree with reality, so this must report zero blocking discrepancies on real
52+
tenant data, on consecutive runs, before the gated collection change may merge.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/formula": minor
4+
"@objectstack/lint": minor
5+
"@objectstack/service-automation": minor
6+
"@objectstack/plugin-approvals": minor
7+
"@objectstack/rest": minor
8+
---
9+
10+
Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities:
11+
12+
**`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate.
13+
14+
**`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals.
15+
16+
**Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `<nodeId>.<key>` variables — a later approval node's expression can read `vars.<nodeId>.picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot).
17+
18+
Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(objectql): document that `treatAsHistorical` also preserves the original audit timeline (#3493). Docs-only — releases nothing, so this changeset is intentionally empty.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-security": patch
4+
---
5+
6+
fix(i18n): translate the SSO / SCIM / user-position / import-job admin objects
7+
8+
Four live, UI-facing system objects were registered but never added to their
9+
package's i18n extract config, so non-English admins saw raw English `label`
10+
metadata:
11+
12+
- `sys_sso_provider`, `sys_scim_provider` (platform-objects) — identity-provider
13+
admin grids plus the register / verify-domain actions.
14+
- `sys_user_position` (plugin-security) — delegated position assignment
15+
(`userActions` create/edit/delete); its sibling `sys_user_permission_set` was
16+
already translated, so this closes an inconsistency.
17+
- `sys_import_job` (platform-objects) — import history / progress, alongside the
18+
already-translated `sys_job` / `sys_job_run`.
19+
20+
Adds each object to its package's `scripts/i18n-extract.config.ts` and supplies
21+
real zh-CN / ja-JP / es-ES translations across all four locale bundles, and
22+
extends the bundle-ownership guards' `OWNED_OBJECTS` to cover them. The
23+
orphan-only guards from #3502 could not catch this "owned-and-live-but-never-
24+
extracted" gap.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): replace the polynomial-redos trailing-slash regex in the notifications domain with split+filter (CodeQL high, surfaced by #3507)
6+
7+
The legacy `path.replace(/\/+$/, '')` in the notifications handler had
8+
carried a polynomial-backtracking regex over request-controlled input since
9+
ADR-0030; the domain extraction (#3507) made the line "changed code" and
10+
CodeQL flagged it. Same split+filter treatment the security domain already
11+
uses for the identical pattern. Redundant slashes in the sub-path now
12+
collapse (`//read//``read`), matching the security domain's semantics.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: prune the still-dead aspirational config from Theme / Translation / Webhook (#3494)
6+
7+
Removes the authorable-but-never-consumed props confirmed dead by the 2026-06
8+
liveness audit (follow-up to #1878/#1893; same treatment as the #2377 and
9+
#3464 prunes). Authoring any of these was a silent no-op.
10+
11+
## Removed
12+
13+
**Theme** (`ThemeSchema`) — the theme engine (objectui `generateThemeVars`)
14+
never emitted or consumed them:
15+
- Props: `spacing`, `breakpoints`, `logo`, `density`, `wcagContrast`, `rtl`,
16+
`touchTarget`, `keyboardNavigation`
17+
- Exports: `SpacingSchema`, `BreakpointsSchema`, `DensityModeSchema` (+
18+
deprecated `DensityMode` alias), `WcagContrastLevelSchema` (+ deprecated
19+
`WcagContrastLevel` alias), and the `Spacing` / `Breakpoints` /
20+
`DensityMode` / `WcagContrastLevel` types
21+
22+
**Translation** (`TranslationConfigSchema`) — no runtime reader; there is no
23+
ICU engine and interpolation is always simple `{variable}` substitution:
24+
- Props: `fileOrganization`, `messageFormat`, `lazyLoad`, `cache`
25+
- Exports: `MessageFormatSchema`, `TranslationFileOrganizationSchema`, and the
26+
`MessageFormat` / `TranslationFileOrganization` types
27+
28+
**Webhook** (`WebhookSchema`) — the delivery path always sends its own fixed
29+
envelope and only applies HMAC signing via `secret`; delivery retries are owned
30+
by the messaging outbox's fixed schedule:
31+
- Props: `body`, `payloadFields`, `includeSession`, `authentication`
32+
(bearer/basic/api-key were never attached; HMAC via `secret` stays),
33+
`retryPolicy`, `tags`
34+
- Exports: the entire inbound `WebhookReceiverSchema` + `WebhookReceiver` type
35+
(never consumed by any runtime)
36+
37+
## Migration
38+
39+
Delete these keys from your configs — they never did anything, so removing
40+
them changes no behavior. Parsed output no longer contains the previously
41+
defaulted keys (`includeSession: false`, `fileOrganization: 'per_locale'`,
42+
`messageFormat: 'simple'`, `lazyLoad: false`, `cache: true`). Webhook HMAC
43+
signing (`secret`), `headers`, and `timeoutMs` are unaffected. File layout for
44+
translations remains a pure authoring convention — no config knob needed.
45+
46+
## Deliberately NOT removed
47+
48+
- Translation `supportedLocales` — it has a live reader (pinyin-search
49+
capability toggle in `serve.ts`).
50+
- Job `retryPolicy` / `timeout` — being implemented (built, not pruned) in the
51+
#3494 follow-up PR.
52+
- The materialized webhook props (`name`, `object`, `triggers`, `url`,
53+
`method`, `headers`, `timeoutMs`, `secret`, `isActive`, `description`,
54+
`label`) — live via the #3489 bridge; ledger flip tracked in #3490.
55+
56+
Refs #3494, #1878, #1893.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract /keys, /storage and /ui dispatcher domain bodies — ADR-0076 D11 step ③, PR-3 (#2462)
6+
7+
Continues the per-domain decomposition: three more handler bodies move out
8+
of `HttpDispatcher` into `domains/keys.ts` (incl. the zero-tolerance
9+
API-key-mint security contract), `domains/storage.ts` and `domains/ui.ts`,
10+
running on the explicit `DomainHandlerDeps` contract (extended with
11+
`getObjectQL` for the data-plane domains). The `/keys` legacy branch's
12+
`'/keys?'` query-string form is reproduced with a second registry entry;
13+
storage drops its strictly-redundant `kernel.services` index-access fallback
14+
(dead under Map-shaped services, duplicate under object-shaped ones). Thin
15+
`handleXxx` delegates remain for direct callers. Zero behavior change —
16+
locked by the 41-assertion http-conformance suite and 6 new seam tests.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract the /share-links dispatcher domain body — ADR-0076 D11 step ③, PR-4 (#2462)
6+
7+
The share-link capability-token surface (ADR-0047) moves out of
8+
`HttpDispatcher` into `domains/share-links.ts`. This is cloud's designed
9+
primary surface for per-env kernels (`registerShareLinkRoutes: false`, host
10+
dispatcher serves after kernel swap — the #2462 step-① re-scope finding), so
11+
the handler keeps working from the registry exactly as from the if-chain.
12+
`DomainHandlerDeps` grows `getRequestKernelService` (reads off the
13+
per-request RESOLVED kernel — the engine the shareLinks service is bound to)
14+
and `routeNotFound` (the shared 404 envelope). Zero behavior change — locked
15+
by http-conformance (41) and 5 new seam tests incl. token-resolve redaction.

content/docs/automation/approvals.mdx

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,50 @@ one node, no parallel branches needed:
143143
A single **rejection** always finalizes the node as `rejected` (one veto), in
144144
every mode. `minApprovals` is clamped to the resolvable approver count / group
145145
size, so it can never deadlock. A decision may also carry `attachments` (file
146-
references) recorded on its audit row — e.g. a signed contract. Weighted voting
147-
and approval-matrix governance are enterprise, not here.
146+
references) recorded on its audit row — e.g. a signed contract — and, when the
147+
node declares `decisionOutputs`, structured `outputs` the flow receives as
148+
`<nodeId>.<key>` variables (see [Dynamic approvers](#dynamic-approvers-3447)
149+
below). Weighted voting and approval-matrix governance are enterprise, not here.
150+
151+
## Dynamic approvers (#3447) [#dynamic-approvers-3447]
152+
153+
Two approver kinds bind to **live** data at the moment the node is entered —
154+
not the submit-time snapshot — so a step can route on data produced after
155+
submit:
156+
157+
- **`field`** reads the record's live field value at node entry. An earlier
158+
step (or an earlier step's approver) can write the routing field mid-flow and
159+
the next approval node sees it; a multi-select user field fans out into one
160+
approver slot per user.
161+
- **`expression`** computes the slate with a CEL expression over exactly three
162+
roots: **`current.*`** (the record's live state at node entry), **`trigger.*`**
163+
(the submit-time snapshot — what a flow condition calls `record`), and
164+
**`vars.*`** (flow variables, including node outputs and `vars.previous`).
165+
`record` and bare field names are deliberately unavailable — at an approval
166+
node "the record" is ambiguous between two times, so the expression must say
167+
which one; referencing anything else fails the node loudly at entry rather
168+
than resolving a silently-empty slate. The optional
169+
`resolveAs: 'user' (default) | 'department' | 'position' | 'team'` re-expands
170+
each resolved id through the same graph lookups the static types use — and
171+
with `behavior: 'per_group'`, each intermediate value (e.g. each returned
172+
department) forms its own sign-off group.
173+
174+
A result may legitimately be **empty** (a present-but-empty field or variable);
175+
the node-level `onEmptyApprovers` policy decides what that means —
176+
`admin_rescue` (default: the request opens, a privileged admin takes over via
177+
Reassign), `fail` (the node fails: an empty slate is a config bug), or
178+
`auto_approve` (skip the request and continue down `approve` with
179+
`output.autoApproved = true`; opt-in, since it waves the record through). A
180+
**missing** key (`vars.never_written`) is a loud error instead — guard
181+
genuinely-optional inputs with `has(vars.x) ? vars.x : []`.
182+
183+
The "previous approver picks the next step's approvers" loop needs no record
184+
field at all: declare `decisionOutputs: ['next_reviewers']` on node A, have the
185+
approver decide with `{ outputs: { next_reviewers: ['u2', 'u3'] } }`, and let
186+
node B's approver be
187+
`{ type: 'expression', value: 'vars.<nodeA-id>.next_reviewers' }`. The author
188+
declares output keys, approvers only fill values; undeclared keys reject the
189+
decision, and `decision` / `requestId` are reserved.
148190

149191
## The full lifecycle — submit to field change
150192

@@ -165,9 +207,12 @@ Only `approvers` is required on the node; everything else has a default
165207
(`behavior: 'first_response'`, `lockRecord: true`, `maxRevisions: 3`).
166208

167209
Approver entries resolve by kind — `position`, `user`, `field`, `manager`,
168-
`team`, `department`, `queue`, and `org_membership_level`, described in the
169-
[callout above](#3-the-approval-node), which is the one that silently resolves
170-
to nobody when it's mistaken for a business hierarchy. An entry that resolves
210+
`team`, `department`, `queue`, `org_membership_level`, and `expression`
211+
(described in the [callout above](#3-the-approval-node) and in
212+
[Dynamic approvers](#dynamic-approvers-3447)); `org_membership_level` is the
213+
one that silently resolves to nobody when it's mistaken for a business
214+
hierarchy. `field`, `manager`, and `expression` resolve against the record's
215+
**live** state at node entry (#3447). An entry that resolves
171216
to nobody is not an error: the request opens with an empty `pending_approvers`
172217
and nothing can move it, so the run parks forever.
173218

@@ -246,7 +291,7 @@ continuity levers on the same request. All are `POST
246291

247292
| Verb | Route | Who | Effect |
248293
|:---|:---|:---|:---|
249-
| `approve` / `reject` | `/approve` `/reject` | pending approver | Records the decision (finalizes per `behavior`). Accepts `comment`, `attachments`. |
294+
| `approve` / `reject` | `/approve` `/reject` | pending approver | Records the decision (finalizes per `behavior`). Accepts `comment`, `attachments`, and — when the node declares `decisionOutputs` — structured `outputs` handed to the flow as `<nodeId>.<key>` variables. |
250295
| `reassign` | `/reassign` | pending approver | Hands one slot to another user (`to`); the request stays pending. |
251296
| `revise` (send back) | `/revise` | pending approver | Ends this round as **`returned`**, unlocks the record for rework (ADR-0044). |
252297
| `request-info` | `/request-info` | pending approver | Asks the submitter for more info; the request **stays pending**. `comment` required. |

0 commit comments

Comments
 (0)