Skip to content

Commit 2bb2fe4

Browse files
committed
Merge origin/main into claude/approval-handler-value-lookup-xytdsd
# Conflicts: # packages/spec/src/automation/approval.zod.ts
2 parents 66eac1f + f390eae commit 2bb2fe4

131 files changed

Lines changed: 8756 additions & 2692 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/service-storage": minor
3+
---
4+
5+
feat(storage): legacy file-value backfill — ADR-0104 D3 wave 2 (PR-6)
6+
7+
`backfillFileReferences()` converts the pre-reference forms a `file`/`image`/
8+
`avatar`/`video`/`audio` field may hold — an inline metadata blob
9+
(`{url, name, size, …}`) or a bare URL string — into the reference form: an
10+
opaque `sys_file` id, owned by the record's field.
11+
12+
What it will and will not convert:
13+
14+
- **A URL naming this platform's own resolver** (`…/storage/files/:id`) already
15+
identifies a `sys_file`; the field is rewritten to the bare id and no bytes
16+
move.
17+
- **A `data:` URI** carries its bytes inline; they are uploaded, a `sys_file` is
18+
registered, and the field is rewritten to its id.
19+
- **An external URL** is reported, never converted. Re-hosting third-party
20+
content is a bandwidth, licensing and privacy decision that is not a
21+
migration's to make — ADR-0104 R7 retires these toward an explicit `url`
22+
field, which under AI authoring is the point: it stops "managed file" and
23+
"external link" being the same declaration.
24+
25+
**Dry run by default** — nothing is written unless `apply` is set, and the
26+
dry-run report has the same shape as the applied one so the plan can be reviewed
27+
and diffed. **Idempotent** — a value already in reference form is recorded and
28+
left alone, so a partially-completed run is safe to repeat.
29+
30+
The backfill never writes the ownership columns itself: it rewrites the record,
31+
and the claim hooks observe that write and record ownership. One claiming path,
32+
so there is nothing that can disagree with itself. Run
33+
`verifyFileReferences()` afterwards to confirm the two agree — that
34+
reconciliation is the gate the irreversible collection change must pass.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/plugin-approvals": patch
4+
---
5+
6+
Surface the approval node's author-declared `decisionOutputs` keys on the request read as `ApprovalRequestRow.decision_outputs` (#3447 P2 UI enablement). The set varies per request (each node declares its own), so it rides the row rather than the object's static action params — a decision UI renders one input per key and POSTs `outputs` with the decision.
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(spec): correct the `field.readonly` describe re: import exemption (#3493). It listed "import" as a system-context exemption, but a normal data import is non-system and still strips; the strip is bypassed only by `isSystem` writes (seed replay, migration) and by an opt-in "historical" import (`preserveAudit`), which admits a whitelist. Describe-string only — regenerates `references/data/field.mdx`, releases nothing.
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): undo of a historical import now preserves the audit timeline (#3549)
6+
7+
A `treatAsHistorical` import writes with `preserveAudit` (#3493), keeping the
8+
original `updated_at`/`updated_by` and business `readonly` fields instead of
9+
stamping-now / stripping them. Its undo route, however, restored the captured
10+
pre-import snapshot with a plain write context — so the audit auto-stamp
11+
re-wrote `updated_at`/`updated_by` to "now", silently corrupting the very
12+
timeline the historical import had preserved.
13+
14+
The undo write context now mirrors the import's own: it carries
15+
`preserveAudit` iff the job row is flagged `treat_as_historical`, so restoring
16+
`u.before` re-writes the snapshotted audit/timestamp values verbatim. A normal
17+
import's undo is unchanged (default stamp/strip).
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-job": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
feat(job): honor the authored `retryPolicy` / `timeout` in the job scheduler (#3494)
8+
9+
`JobSchema.retryPolicy` and `JobSchema.timeout` used to be parsed-but-ignored
10+
(the 2026-06 liveness audit's aspirational-config cluster). They are now
11+
enforced end to end — built rather than pruned, since retry/backoff and
12+
per-run time limits are semantics job authors reasonably expect:
13+
14+
- **spec**: `IJobService.schedule` gains an optional 4th `options` argument
15+
(`JobScheduleOptions` with `retryPolicy` / `timeout`, mirroring the
16+
authorable schema); new `JobRetryPolicy` type. Backward compatible —
17+
existing 3-arg implementations and callers are unaffected.
18+
- **service-job**: new `runWithPolicy` helper (exported, with
19+
`JobTimeoutError`) wraps every handler invocation in `CronJobAdapter` and
20+
`IntervalJobAdapter`; `DbJobAdapter` threads options through to its inner
21+
adapters. Failed attempts (including timeouts) retry with exponential
22+
backoff `backoffMs * backoffMultiplier^(retry-1)` up to `maxRetries`;
23+
an attempt exceeding `timeout` is recorded with execution status
24+
`'timeout'`. No `options` → exactly the legacy single-attempt behavior.
25+
- **runtime**: declarative-jobs registration in AppPlugin forwards the
26+
authored `retryPolicy` / `timeout` to the scheduler.
27+
28+
Note: JavaScript cannot forcibly cancel an in-flight handler — a timed-out
29+
attempt is abandoned, not killed. The retry delay caps only via the
30+
multiplier arithmetic (no maxDelay knob yet).
31+
32+
Refs #3494, #1878, #1893.
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.

0 commit comments

Comments
 (0)