Skip to content

Commit 9a35e04

Browse files
committed
Merge origin/main into claude/issue-20749-spec-strings-stage5
Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
2 parents 9c1d040 + 5dbcee8 commit 9a35e04

23 files changed

Lines changed: 1816 additions & 138 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
fix(runtime)!: the in-process reader contexts refuse the stored-metadata-body family's EVALUATE shapes and serve what a write returns, the way the generic data door does (#21454)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no metadata body, authorable key, spelling, export or stored shape moves; what changes is which query shapes the in-process reader contexts accept over the two stored-metadata tables, and the form in which a write's returned row is served, so `objectstack migrate meta` has nothing to rewrite. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers a refused query shape (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: this narrows what an action or hook body's object API, an action handler's scoped API and an action handler's engine handle accept when they read the two stored-metadata tables. A read there that filters, sorts or groups on the stored body column or on a content-hash column, a read that names one of those columns in an explicit search-field list, and a `count` carrying such a filter, ran before this release and now answer the generic data door's `400 INVALID_FIELD` before the query runs. The route: filter, sort, group and search those tables by their scalar columns (the type, the name, the state and the like), and read the bodies with a plain list, which is served projected — the body as its type's read projection, the content hash in keyed form. A default search with no field list is not refused: it is narrowed to the columns the door serves. Every other column of the two tables, and every other object, is unchanged. It ships as `minor` under the launch-window convention for accept-set narrowings.
13+
14+
- **`@objectstack/metadata-protocol`** now exports the generic data door's four evaluate-refusal predicates — `storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal` and `storedMetadataSearchRefusal` — so the `@objectstack/runtime` reader-context seam refuses the same shapes through the door's own predicates rather than a second copy. Additive: nothing that imported the package before is changed.
15+
- **`@objectstack/runtime`** extends the stored-metadata reader-context seam (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`): a filter, sort, grouping or search that would evaluate the stored body or content hash of `sys_metadata` / `sys_metadata_history` is refused with the door's `INVALID_FIELD` / 400 before the query runs (a `count` with such a predicate included); a default `$search` is narrowed to the door's served field set rather than refused; and the row a write verb returns is served projected and keyed. The engine's own action verb (`ScopedRepo.execute`) is unreachable from a served body and is left untouched.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
Withdrawing or publishing a public form on a walled tenancy posture (degraded or not) is now refused loudly at authoring, with `403 NOT_OVERRIDABLE`, when the save is organization-scoped and the anonymous form doors cannot honour it. The message names the remedy: save the change env-wide, which every anonymous door honours. Drafts and draft promotion are refused alike. Other organization-scoped edits, env-wide saves and single-posture deployments are unchanged.

‎.changeset/21498-cli-compose-migration-recovery.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ Clause-②: no
1010

1111
- **The `os migrate` data commands** (`recorded-by`, `resume`, `value-shapes`, `summary-nulls`, `files-to-references`, `meta --stored`, `audit-metadata-bodies`, `os storage orphans`) now boot with the plugin. A run interrupted before any of its chunks committed now resumes to completion. A command booted over an interrupted run also warns about that run on stderr first.
1212
- **Every `os serve` boot** (and so `os start` and `os dev`, which spawn it) composes the plugin beside `PlatformObjectsPlugin`, which registers the journal the scan reads. An interrupted run is reported once at boot, with the `os migrate resume --run <id>` command that resumes it. Nothing is resumed automatically. A database with no interrupted run prints nothing. A config that composes its own `new MigrationRecoveryPlugin()` keeps that instance.
13-
- **Still refused:** a `recorded-by` run that had committed a chunk before it was interrupted, or that was started with a non-default `--chunk-size`. `resume` now reaches the runner for these runs, and the runner refuses them with `PLAN_CHANGED`. Re-running `os migrate recorded-by --apply` converts whatever rows still hold the sentinel.
13+
- **A run that had committed a chunk, or that was started with a non-default `--chunk-size`,** reaches the runner too. The runner fix that lets it resume is in the `@objectstack/core` entry for #21528.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the object door lists a stored view row under its own name even where a stored view container expands that name, as the by-name read already answers
6+
7+
Clause-②: no
8+
9+
- **What changed.** `GET /api/v1/meta/view?object=…` (the object door) no longer lets a stored view container's expansion replace a stored row of the same name. A view item (a row carrying `viewKind`) saved under a name the container also expands, such as `<object>.default` beside a stored overlay of that object's container, is now what the object door lists under that name. Before, the object door listed the container's expansion there while the by-name read (`GET /api/v1/meta/view/NAME`) answered the stored row. Both doors now answer the row.
10+
- **The rule.** A row stored under exactly a name is the override for that name (ADR-0005 keys an overlay by its own name). An expansion fills only the names that have no row of their own. The list read and the by-name read decide this with one test, over the rows each selects for the same caller, so a row stored for one organization does not hide the expansion from any other caller.
11+
- **A container stored under one of its own expanded names.** That row is the name's own row as well, so its expansion no longer fills the name. The object door never lists a container, so it now lists nothing under that name. Before, it listed the container's expansion there. The by-name read answers the stored container, as before.
12+
- **What does not change.** Every name a container expands that has no stored row of its own is still listed, and on both doors it still replaces a packaged view of the same name. The by-name read answers as before. The save door is unchanged. No response shape gains or loses a key.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The `ApprovalActionRow` documentation now says what `reassign_from` and `reassign_to` hold. It said both were users. They hold a slot address in its stored spelling: a user id, an email, or a position address such as `position:legal`. A reassignment moves a slot, not necessarily a person, and the person who made the move is `actor_id`. The `reassign_from_name` and `reassign_to_name` documentation now says when a name resolves: only for a user id, or for an email an account carries. A position address never resolves, so a consumer renders the address when the name is absent.
6+
7+
Clause-②: no
8+
9+
Documentation only. No schema, export or type changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): a resumed migration run is compared against the chunk plan it started over, so `os migrate resume` completes an interrupted `recorded-by` run that had committed a chunk or was started with a non-default `--chunk-size` (#21528)
6+
7+
Clause-②: no
8+
9+
`runMigrationJournal` recomputed a resumed run's chunk plan from the rows `load()` returned at resume time, at the plan's current chunk size, and refused `PLAN_CHANGED` when that plan's hash differed from the one `run_started` recorded. Two kinds of interrupted run could differ. A plan whose `load()` selects only the work still to do, which `recorded-by`'s plan does, returns fewer rows once a chunk has committed. And the plan handed back for a resume carries its own chunk size, not the one the run was started with. So `os migrate resume` listed such a run as `resumable: true`, and `os migrate resume --run <id> --yes` then refused it.
10+
11+
A resume now reads the chunk plan back from the journal's `run_started` record:
12+
13+
- **Identity.** The plan's id and step names are hashed with the recorded chunk boundaries and compared with the recorded hash. A plan whose id or steps changed is still refused `PLAN_CHANGED`. The run resumes at the chunk size it started with.
14+
- **Rows.** Each step's rows are bound to that chunk plan. If `load()` returns every row the run started over, each chunk's rows are where the journal put them, as before. If it returns exactly the rows of the chunks not yet committed, those rows go, in order, to those chunks. Any other row count is refused `PLAN_CHANGED`, and the message names the step.
15+
- **Unwind.** If a chunk fails after a resume that bound its rows the second way, the runner compensates the chunks this process committed, newest first. It then stops at the newest chunk an earlier process committed and journals `run_failed`, because `load()` no longer returns that chunk's rows. It does not compensate other rows in their place, and the run ends `failed`.

‎docs/adr/0042-approval-sla-escalation.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# ADR-0042: Approval SLA escalation — a jobs-backed scanner with audit-row idempotency
22

33
**Status**: Accepted — implemented (proposed 2026-06-12 · calibrated 2026-06-12)
4+
· **Superseded in part (2026-08-02, [ADR-0118](./0118-non-user-actor-contract.md) D1)** — §2's reserved actor `system:sla`, wherever this record names it: machine actions record `actor_id` null, and the `escalate` row is the attribution.
45
**Deciders**: ObjectStack Protocol Architects
56
**Builds on**: [ADR-0019](./0019-approval-as-flow-node.md) (approval as flow node), [ADR-0041](./0041-flow-trigger-family.md) (triggers vs jobs vs hooks — this is the canonical "jobs, not trigger" case), thread interactions (#1740)
67
**Closes**: [#1742](https://github.com/objectstack-ai/objectstack/issues/1742)

‎docs/qa/platform-checklist/areas/approvals.json‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,7 @@
811811
"title": "A node's SLA escalation fires once past its timeout — the declared action runs and an escalate timeline row lands",
812812
"since": "v16",
813813
"status": "active",
814-
"revision": 1,
814+
"revision": 2,
815815
"priority": "P2",
816816
"surface": "api",
817817
"personas": ["dev admin"],
@@ -832,7 +832,7 @@
832832
"in a writable package author + register a flow whose approval node config.escalation = {enabled:true, timeoutHours:1, action:'reassign', escalateTo:'<position machine name or user id>', notifySubmitter:true}",
833833
"trigger the flow; GET /api/v1/approvals/requests/:id — status=pending and the request carries sla_due_at = created_at + timeoutHours (the SLA is materialized on open)",
834834
"[needs clock control] advance the clock past sla_due_at (inject a clock / drive ApprovalService.runEscalations() with a clock whose now() is beyond the deadline) and run one escalation sweep",
835-
"GET /:id/actions — assert exactly one action='escalate' row (the audit-first idempotency marker, actor SLA_ACTOR_ID) whose comment names the action",
835+
"GET /:id/actions — assert exactly one action='escalate' row (the audit-first idempotency marker) whose comment names the action and which carries no actor: actor_id is absent on the read (stored null), because a machine action records no actor and the escalate row is the attribution (ADR-0118 D1)",
836836
"assert the declared action's effect: reassign → pending_approvers swapped to the escalatees + an approval.escalated notification to them; auto_approve/auto_reject → request finalized approved/rejected and the owning run resumes; notify → an approval.sla_breached notification to the pending approvers",
837837
"with notifySubmitter!==false, read the submitter's inbox — an approval.sla_breached notification addressed to them",
838838
"idempotency: run the sweep a SECOND time — GET /:id/actions shows NO second escalate row (single-shot, marker-guarded)",
@@ -848,7 +848,7 @@
848848
{
849849
"clause": "past the deadline the sweep escalates exactly ONCE: one action='escalate' timeline row, and a re-run adds none",
850850
"oracle": "api",
851-
"verify": "after advancing past sla_due_at and sweeping, GET /:id/actions has exactly one action='escalate' row (actor SLA_ACTOR_ID); a second sweep adds no further escalate row (the audit row is the idempotency marker, written before any mutation)",
851+
"verify": "after advancing past sla_due_at and sweeping, GET /:id/actions has exactly one action='escalate' row with no actor (actor_id absent on the read, stored null — ADR-0118 D1: a machine action records no actor, and the escalate row is the attribution); a second sweep adds no further escalate row (the audit row is the idempotency marker, written before any mutation)",
852852
"evidence": "actions reads after the first and second sweeps"
853853
},
854854
{
@@ -882,7 +882,8 @@
882882
"packages/plugins/plugin-approvals/src/sys-approval-request.object.ts (sla_due_at surfaced on the request)"
883883
],
884884
"history": [
885-
{ "revision": 1, "date": "2026-08-08", "change": "initial — pins the ADR-0042 SLA escalation (declared action fires once past timeout + escalate timeline row); blocked on a clock-control timing harness (hour granularity), with the sla_due_at materialization and the strict-schema build clause runnable today", "ref": "claude/platform-test-checklist-ocwugl" }
885+
{ "revision": 1, "date": "2026-08-08", "change": "initial — pins the ADR-0042 SLA escalation (declared action fires once past timeout + escalate timeline row); blocked on a clock-control timing harness (hour granularity), with the sla_due_at materialization and the strict-schema build clause runnable today", "ref": "claude/platform-test-checklist-ocwugl" },
886+
{ "revision": 2, "date": "2026-10-03", "change": "the escalate row's expected actor corrected: step 5 and the single-shot clause asserted actor SLA_ACTOR_ID, but under ADR-0118 D1 the sweep records no actor (actor_id null, absent on the GET /:id/actions read) and the escalate row is the attribution, so a run following the old text would report a false failure. Nothing else in the item changes", "ref": "#21517" }
886887
]
887888
},
888889
{

0 commit comments

Comments
 (0)