Skip to content

Commit e40ef1f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22725-admin-rescue-queue-arm
2 parents bb11218 + e84aeb3 commit e40ef1f

59 files changed

Lines changed: 3073 additions & 158 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
feat(runtime): `POST /api/v1/security/_activation/:type/:name` switches a position or a permission set on or off for the deployment (ADR-0126 §3 regime C, as ADR-0131 D6 amends it)
6+
7+
Clause-②: yes
8+
9+
The authorization resolver reads whether a position or a permission set is switched off from the activation ledger, `sys_metadata_activation` (types `position` and `permission`), and never from the catalog row's `active` column (ADR-0131 D3). Until now nothing wrote that ledger for these two types, so a deactivation switched nothing off. This route is the write half:
10+
11+
- **Route:** `POST /api/v1/security/_activation/:type/:name`, with `:type` either `position` or `permission`. It is also mounted under `/api/v1/environments/:environmentId` when project scoping is on, like the action door.
12+
- **Body:** `{ enabled?: boolean }`. `enabled` defaults to `true`. Unknown keys and a non-boolean `enabled` are refused `400 VALIDATION_FAILED`, the same body reader `POST /actions/_activation/:object/:action` uses.
13+
- **Effect:** one `sys_metadata_activation` row through the engine, carrying the definition's package. Re-enabling updates that row. Switching a position off stops every grant through it for every holder in the deployment. Switching a permission set off stops it granting by every path. No definition and no catalog row is written.
14+
- **Authority:** the same as the flow and action activation doors. The caller needs `manage_metadata`. Under a `group` or `isolated` tenancy posture the caller must also be the platform operator (ADR-0126 §5). Refusals are `403 PERMISSION_DENIED`, and nothing is written.
15+
- **Refusals:** the audience anchors `position/everyone` and `position/guest` are refused `400` in either direction, before anything is read or written, because one row would switch the authenticated or anonymous baseline off for every principal. A name that does not resolve in the security catalog is `404`. No catalog bound, or a catalog that cannot be read, is `503 SERVICE_UNAVAILABLE`. A composition without the ledger object is `501`. Switching `admin_full_access` off is refused `403 PERMISSION_DENIED` by the last-admin guard's ledger hook.
16+
17+
The route is not in the JS SDK. Its caller is the Setup console's Deactivate on the position and permission-set pages, which calls the platform API directly.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/metadata-protocol': minor
5+
'@objectstack/client': minor
6+
---
7+
8+
A write answer now carries the advisory validation-rule hits of that write as `warnings`
9+
10+
Clause-②: yes (widening)
11+
12+
A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries.
13+
14+
- **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write.
15+
- **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before.
16+
- **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`.
17+
- **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only.
18+
- **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`.
19+
20+
**For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-approvals': minor
3+
'@objectstack/plugin-audit': minor
4+
---
5+
6+
fix(plugin-approvals,plugin-audit)!: a lookup target's title is read only when the TARGET object's declared exposure serves `get` — the approvals inbox's `payload_display` and the activity summary (#22738)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- 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, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is two served titles: each stops naming a lookup's target object whose existing `enable` declaration already refuses `get` on every data route, and the remedy is the declaration the author already wrote. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
11+
12+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
The data routes judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`), and the data door's `$expand` and the dataset door's labels already ask it of a lookup's TARGET. Two more reads follow a lookup to its target's title under a system context and never asked it.
15+
16+
**FROM.** For an administrator and a member alike:
17+
18+
- **Approvals inbox** (`@objectstack/plugin-approvals`): `GET /api/v1/approvals/requests` and `GET /api/v1/approvals/requests/:id` carried, in `payload_display`, the title of a snapshot lookup's target whose declaration refuses `get`.
19+
- **Activity summary** (`@objectstack/plugin-audit`): an update's tracked-change summary, and a fired milestone's summary, named such a target's records by title in the `sys_activity` row served by `GET /api/v1/data/sys_activity`.
20+
21+
**TO.** Each title read first asks the decision of the TARGET object, for `get` (turning an id into the record it names, the read `GET /api/v1/data/:target/:id` performs):
22+
23+
- A refused target is not read. The inbox carries no `payload_display` entry for that key, so the snapshot's stored id stands, which is what a deleted target already answers. The activity summary names the record by its stored id, which is what an unresolvable reference already answers.
24+
- A declaration that cannot be read withholds the title too (fail-closed, at `warn`).
25+
- Only activity rows written from now on change. A written row is a snapshot and is never rewritten, and no other activity column changes.
26+
27+
A target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']`) is withheld; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. The decision takes no caller.
28+
29+
**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). So no shipped object's served title changes. Deployed and cloud-held object definitions were NOT MEASURED.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): a new webhooks service contract declares an optional, transport-neutral redeliver member (`IWebhookService.handleRedeliver`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** `@objectstack/spec/contracts` exports a new contract, `IWebhookService`, for the `webhooks` service slot that `@objectstack/plugin-webhooks` fills. It has one optional member, `handleRedeliver?(request: Request): Promise<Response>`. It serves the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, the operator button that sends a finished delivery again.
10+
- **What it serves.** The same answers as the plugin's self-hosted route. It authenticates the caller from the request's own credentials (`401 UNAUTHENTICATED` when nobody is signed in). It reads `{ "deliveryId": string }` from a JSON body (`400` otherwise). It replays the row through the messaging service, scoped to the caller's active organization (`404 RESOURCE_NOT_FOUND` for a row outside it). It answers `409` with `DELIVERY_NOT_ELIGIBLE` or `DELIVERY_NEVER_SENT` when the outbox refuses the replay, and `200` with the row's id and new status when it succeeds.
11+
- **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's `POST /webhooks/redeliver` domain is the member's one caller.
12+
- **Why a webhooks slot, not the messaging service's.** The messaging service owns the replay itself. The door (who may press it, whose rows it reaches, what each refusal answers, and the veto over replaying a webhook whose subscription or secret is gone) belongs to the webhook plugin, so the door exists exactly where that plugin is composed.
13+
- **Absence.** The member is optional, so a webhook service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `webhooks` service or this member is absent, never `ROUTE_NOT_FOUND`.
14+
- **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `webhooks` slot or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted route stays as it is.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
140140
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
141141
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. |
142142

143+
A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable.
144+
143145
The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged.
144146

145147
An environment row stored over a managed item of a type sealed against overlays (a flow, action, hook, object or datasource, for example — any type whose registry entry admits no overlay) before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: it keeps the detection reading and the operator's Discard Overlay action it already had.

‎content/docs/data-modeling/validation.mdx‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,32 @@ All validation types share these base properties:
8484
| Severity | Behavior |
8585
| :--- | :--- |
8686
| `error` | Prevents the record from being saved |
87-
| `warning` | Allows the save; advisory only — logged server-side, not returned to the caller |
88-
| `info` | Informational message, no blocking |
87+
| `warning` | Allows the save; advisory only — returned to the caller in the write answer's `warnings`, and logged server-side |
88+
| `info` | Informational message, no blocking; returned and logged the same way as `warning` |
8989

9090
Advisory rules (`warning` / `info`) are reported, never enforced: on an ordinary write each hit is logged as it happens; on a seed/boot load a run's hits are folded into **one summary line per rule**; and an `update` touching only platform-injected system columns does not re-evaluate them at all, so a row is reported once rather than once per write (#13889).
9191

92+
The write's caller sees them too. A create, an update or a clone whose record trips an advisory rule still succeeds, and its answer carries `warnings`, one entry per rule hit: the rule's `name` as `rule`, its `severity`, the `field` it is about (`_record` for an object-level rule), the finding `code` and your `message`. A client shows that message to the person who saved. A write with no hit has no `warnings` key at all.
93+
94+
```json
95+
{
96+
"object": "task",
97+
"id": "tsk_001",
98+
"record": { "id": "tsk_001", "name": "Internal chore", "related_to": null },
99+
"warnings": [
100+
{
101+
"rule": "related_to_required",
102+
"severity": "warning",
103+
"field": "_record",
104+
"code": "rule_violation",
105+
"message": "At least one related record should be selected."
106+
}
107+
]
108+
}
109+
```
110+
111+
Only the hits of that one write are listed: a record a hook or a flow writes while handling it reports its own hits to its own caller, not into this answer. The validate-only preview (`validateData`, and an import's dry run) appends the same entries to each accepted row's `warnings`, after any value-shape findings the deployment admits. A rule whose condition could not be evaluated is not a hit: it is reported in the server log only.
112+
92113
## Validation Types
93114

94115
### Script Validation

‎content/docs/kernel/contracts/data-engine.mdx‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -331,10 +331,11 @@ interface EngineUpdateOptions {
331331

332332
### WriteObservabilityOptions
333333

334-
The write methods (`insert` / `update`) additionally accept two **in-process**
335-
options that govern what happens when caller-supplied write fields are legally
334+
The write methods (`insert` / `update`) additionally accept three **in-process**
335+
options. Two govern what happens when caller-supplied write fields are legally
336336
stripped from the payload before the driver write: observe the strip
337-
(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`).
337+
(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). The
338+
third, `onValidationAdvisory`, reports the write's advisory validation-rule hits.
338339

339340
The strips these two options cover are the engine's legal ones:
340341

@@ -354,6 +355,7 @@ on `insert` too since the 2026-09-03 ruling — see **On `insert`** below.
354355
```typescript
355356
interface WriteObservabilityOptions {
356357
onFieldsDropped?: (event: DroppedFieldsEvent) => void;
358+
onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void;
357359
strictReadonlyWrites?: boolean; // refuse instead of stripping. Default: false
358360
}
359361

@@ -364,6 +366,14 @@ interface DroppedFieldsEvent {
364366
// path's legal strips; branch on it exhaustively, never with a binary test
365367
reason: 'readonly' | 'readonly_when' | 'primary_key' | 'computed';
366368
}
369+
370+
interface ValidationAdvisoryEvent {
371+
rule: string; // the advisory validation rule's declared `name`
372+
severity: 'warning' | 'info'; // never `error`: an `error` rule refuses the write instead
373+
field: string; // the rule's first declared field, or `_record`
374+
code: string; // the finding code a refused write reports for the same rule
375+
message: string; // the rule's author-written message, in the caller's locale
376+
}
367377
```
368378

369379
#### `onFieldsDropped` — quiet and observable
@@ -425,6 +435,24 @@ legacy record numbers (#3493) exempts the runtime-owned strip only, and a
425435
non-system create that asks for it still has its static `readonly` fields stripped,
426436
with a `warn` saying the exemption is UPDATE-only (#6640).
427437

438+
#### `onValidationAdvisory` — the write's advisory rule hits
439+
440+
The engine calls the listener once per ADVISORY validation-rule hit on the write
441+
the options were passed to: a `severity: 'warning'` or `'info'` rule whose verdict
442+
on the written record was "violated". Advisory rules never block, so the write
443+
proceeds. It is called per row on `insert`, and on `update` both by id and per
444+
matched row of a `multi` update. A nested write that a hook or a flow makes
445+
inside this one carries its own options, so that write's hits never reach this
446+
listener. A rule that could not be evaluated is not a hit, and is reported in the
447+
server log only. The listener fires when the rules are evaluated, before the
448+
driver write, so a hit is provisional until the write resolves. Passing it
449+
changes no server-side reporting: the per-write `warn` line, and a seed / boot
450+
load's one summary line per rule.
451+
452+
The DataProtocol's `createData`, `updateData` and `cloneData` pass this listener
453+
and answer the hits as `warnings` (`ValidateDataIssue` entries carrying `rule`
454+
and `severity`), present only when there is at least one hit.
455+
428456
<Callout type="warn">
429457
**Layering — this is the engine seam, and since the 2026-09-03 ruling the only
430458
one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`,
@@ -436,15 +464,19 @@ declares one; none carries a `readonly` policy of its own any more — see
436464

437465
<Callout type="warn">
438466
`WriteObservabilityOptions` is a **TS-contract-level, in-process-only** bag —
439-
both members. It is deliberately not part of the serializable Zod options schemas:
467+
all three members. It is deliberately not part of the serializable Zod options schemas:
440468
a function is unrepresentable in JSON Schema and cannot cross the RPC (Virtual
441469
Data Engine) boundary, so remote callers never receive these events; and putting
442470
`strictReadonlyWrites` in the serializable bag would let any client toggle
443471
write-refusal on a security-adjacent path (#5126 ruling). A remote caller can set
444-
neither and gets NEITHER behaviour: its write is stripped and committed, silently
445-
from its side — a 200 whose read-only columns kept their stored values. Widening
446-
strict to the wire is a SEPARATE decision. A listener that throws never breaks the
447-
write — the engine catches and logs.
472+
none of the three, and for the strips it gets NEITHER behaviour: its write is
473+
stripped and committed, silently from its side — a 200 whose read-only columns
474+
kept their stored values. Widening strict to the wire is a SEPARATE decision.
475+
Advisory rule hits do reach a remote caller, through the answer rather than the
476+
listener: the DataProtocol's `createData`, `updateData` and `cloneData` pass
477+
`onValidationAdvisory` in-process and answer the hits as `warnings`, which is how
478+
a remote caller sees them. A listener that throws never breaks the write — the
479+
engine catches and logs.
448480
</Callout>
449481

450482
### delete

0 commit comments

Comments
 (0)