Skip to content

Commit ffb3028

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22769-versioned-signature
Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
2 parents 9cb4481 + c74d843 commit ffb3028

28 files changed

Lines changed: 2351 additions & 68 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/verify': minor
3+
---
4+
5+
feat(verify): the handle gains a door that observes the writes the engine receives, a hook's refused write included
6+
7+
Clause-②: yes (widening)
8+
9+
An app's tests can see what a hook handed the engine through the in-process handle, so a suite no longer wraps the booted engine's write methods in a spy to find a refused write, to wait for a fire-and-forget hook, or to stage a refusal.
10+
11+
- **`stack.observeWrites(fn)`** runs `fn` and resolves with every insert, update and delete the booted engine received meanwhile, in the order each reached it. Each entry (`ObservedWrite`) carries `object`, `operation`, the payload (`data`), the update or delete selection (`where`), `multi`, the execution context the write carried (`context`), and the engine's answer (`outcome`): `pending`, then `resolved` with its result, or `refused` with the error the engine threw, unchanged, and that error's own `code`.
12+
- **What it is.** One global middleware on the engine's own chain (`registerMiddleware`), added on the first call. It passes every operation on unchanged and decides nothing. So it sits after the write gates the boot registered. Every write those gates let through is recorded, from any door (`hooks.run`, `seed`, a flow, a route, a hook's `ctx.api`), and so is every refusal the engine raises past them: a declared validation rule (`VALIDATION_FAILED`), a hook's throw, a unique conflict (`DUPLICATE_RECORD`). A write a gate refuses, the permission check among them, never reaches it and leaves no entry. Its refusal still reaches whoever made the write.
13+
- **An outcome is the engine's answer to that call, not a statement about stored rows.** A write an `after*` hook refuses was refused after the driver wrote, and its row can stay. Read rows back with `stack.rows`.
14+
- **The window is time, not cause.** A write the stack makes for its own reasons while `fn` runs is recorded too, so filter by `object`.
15+
- **An `async: true` hook.** Its write reaches the engine after the write that fired it has returned. `fn` is handed the live observation (`WriteObservation`), and `observation.settled(match)` resolves with the first write `match` accepts once the engine has answered it. It rejects after `timeoutMs` (default 2000), naming every write observed, or when the observation closes first. A hook that writes nothing leaves nothing to observe.
16+
- **Staging a refusal.** Arrange one the engine makes itself: seed the row that already holds a unique value the hook will write, or set up the state a declared validation rule refuses. The handle has no switch that refuses a write in the engine's place.
17+
- A `fn` that is not a function, a `match` that is not a function, and a `timeoutMs` that is not a positive number are refused with `code: 'INVALID_REQUEST'`, `status: 400`. A rejection of `fn` is rethrown unchanged once the observation has closed. No error code is added.
18+
- New exported types: `ObservedWrite`, `ObservedWriteOutcome`, `WriteObservation`.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-approvals": minor
4+
"@objectstack/rest": minor
5+
---
6+
7+
feat(spec,plugin-approvals,rest): the approvals inbox read gets an `unstaffed` arm, so an `admin_rescue` request is in the queue of a caller who may override it
8+
9+
Clause-②: yes (widening)
10+
11+
- **What is new.** `IApprovalService.listRequests`' filter declares an optional `unstaffed?: boolean`. `countRequests` takes the same filter, so it answers it too. `GET /api/v1/approvals/requests` accepts it as the declared query parameter `?unstaffed=true|false`.
12+
- **What it selects.** The PENDING requests whose current slate names no person: no user id and no email, only `type:value` literals such as `position:<p>`, or nothing at all. This is what `onEmptyApprovers: 'admin_rescue'` (the default) opens. The test is the one the open path makes before it warns "decidable only by a privileged admin", applied to the slate as it stands. A slate whose last named approver acted and left only literals matches too. A slate that still names a person never matches, even if that person has left.
13+
- **Who it returns them to.** Only a caller who may override them, judged by the privileged-override rule that admits the override decision itself. Every row it adds reads `viewer.can_override: true`, and a tenant admin's arm stops at their organization. For anyone else the member adds nothing and refuses nothing.
14+
- **How it composes.** It is ORed with `approverId`, never ANDed. `?status=pending&approverId=<my identities>&unstaffed=true` answers "my pending approvals, rescues included" in one call, paged server-side, and `total` counts both arms. Without `approverId` it selects the rescue requests alone. `unstaffed=false` is the same as leaving it out.
15+
- **What is refused.** Any value but `true` or `false` (`1`, `TRUE`, an empty `?unstaffed=`) answers `400 VALIDATION_FAILED` with `fields[0]` `{ field: 'unstaffed', code: 'invalid_boolean' }`, and a repeated `?unstaffed=` answers the route's multiplicity `400`. Nothing changes for a caller who does not send it: `approverId` matches exactly what it matched before.
16+
- **Before this.** The request was in no queue. `approverId` matches a slot against the addresses a caller names, and an override actor's addresses never equal an unstaffed `position:<p>` literal. So no "My Pending" listed it, no bell counted it, and a `lockRecord: true` record stayed locked until an admin browsed the unfiltered list.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
fix(core)!: an import's reference resolution asks the lookup TARGET its declared exposure before matching a cell (#22739)
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, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime read, in two places: the import's reference resolution stops matching cells against a lookup's target object whose existing `enable` declaration already refuses that read on every data route (the remedy is the declaration the author already wrote), and a field of the legacy, schema-refused type spelling `'reference'` no longer takes a target from its raw `reference` key, since the spec's arbiter gives it none (no shipped producer declares one). The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed — `ImportProtocolLike` gains one optional member (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`POST /api/v1/data/:object/import` and the async `POST /api/v1/data/:object/import/jobs` judge the ADDRESSED object's `enable` block. A lookup cell is resolved by matching its text against the lookup's TARGET object — an object nobody addressed.
14+
15+
**FROM.**
16+
17+
- For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart.
18+
- A field of the legacy type spelling `'reference'` (not a `FieldType`, so `ObjectSchema` refuses it, but still listed in the import, export and import-template doors' own type tables) took its target from the raw `reference` key: import matched its cells against that object and export expanded it.
19+
20+
**TO.**
21+
22+
- The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed).
23+
- A field is a reference on those doors exactly when it declares a `reference` string, as before, and its target is read through the spec's arbiter `referenceTargetOf`, which names none for a type outside `lookup` / `master_detail` / `user` / `tree`: such a `'reference'`-typed field's cell is stored as written, and export leaves its stored id. Every other non-reference type was never resolved by these doors and is unchanged. Measured over every git-tracked non-test source under `packages/` and `examples/` (`.ts`, `.js`, `.json`; fixtures excluded) at `0d326bfb12`: no shipped field declares `reference` on a non-reference type (0 object literals, 0 `Field.*` helper calls; the one literal pairing `type` with `reference` is a key-classification table, not a field), against a control of 30 reference-typed literals and 80 `Field.lookup` / `masterDetail` / `user` / `tree` calls; a planted literal and a planted helper call were both found.
24+
25+
**Bindings.**
26+
27+
- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it (plugin-auth's identity import, whose reads run under the system identity) is not judged.
28+
- **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers.
29+
- **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change.
30+
31+
**Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import.
32+
33+
**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` (spec and qa excluded) and `examples/`: twelve objects refuse `list` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (its own import door already answers 404); the control count of lookups into `sys_user` is 75. So no shipped object's import answer changes. Deployed and cloud-held object definitions were NOT MEASURED.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
A `sys_activity` row that an `activityMilestones` entry fired carries `metadata.kind: 'milestone'`, so a reader can tell a milestone from an ordinary update or a finished task
6+
7+
Clause-②: no
8+
9+
ADR-0052 §5 keeps `sys_activity.type` domain-neutral and puts a row's domain kind in
10+
`metadata.kind`. A fired milestone keeps the `type` its declaration names: `completed`, or
11+
`updated` when it names none. Nothing on the served row said it was a milestone. So a "Case
12+
resolved" milestone declared with `type: 'completed'` read as a finished task, and a feed that
13+
hides completed tasks hid it too.
14+
15+
The CRUD mirror now writes `metadata.kind: 'milestone'` on every row a milestone fires. `type` is
16+
unchanged, and a row that no milestone fired carries no `kind`.
17+
18+
The kind is served by the same rule as the text beside it. "A milestone fired" means that a
19+
watched field entered its declared value, so:
20+
21+
- **A reader served every field that a declared milestone watches** is served `metadata.kind`.
22+
- **A reader not served one of those fields** is served the row without `metadata.kind`, just as
23+
it is served the change without that field.
24+
- **A row an app writes itself** keeps its own `metadata.kind` for every reader, as written.
25+
26+
The rest of the redaction is unchanged. The provenance declaration (`metadata.text_sources`) is
27+
never served, and a change key the reader is not served is dropped. Rows written before this change
28+
carry no kind, and this change does not backfill them. Nothing you author changes: no key, option,
29+
export or type is added or removed.

‎content/docs/automation/approvals.mdx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,28 @@ position under as their own identity. The deprecated pre-rename prefix is no
453453
alias of it (ADR-0090 D3): it matches only a slot stored under that exact
454454
spelling, and no holder acts under it.
455455
Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`,
456-
`offset`.
456+
`offset`, and `unstaffed`.
457+
458+
`unstaffed=true` adds the pending requests whose slate names no person: only
459+
unstaffed literals such as `position:<name>`, or nothing at all. This is what
460+
`admin_rescue` opens. They
461+
are added only for a caller who may override them: an admin with override
462+
authority (`admin_full_access`, or `organization_admin` within their
463+
organization). Every such row reads `viewer.can_override: true`. For anyone else the parameter
464+
adds nothing. It is ORed with `approverId`, never ANDed, so one call answers
465+
"my pending approvals, rescues included", and its `total` counts both:
466+
467+
```bash
468+
curl -b cookies.txt \
469+
"https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123&unstaffed=true"
470+
```
471+
472+
It reads `true` or `false` only (`false` is the same as leaving it out); any
473+
other value is refused with `400`. The test reads the slate, not the directory:
474+
a `position:<name>` slot whose position gained a holder after the request opened
475+
still matches (that holder also finds it under `approverId`), and a slate that
476+
still names a person never matches, even if that person has since left.
477+
Reassign such a request from the **All** view.
457478

458479
<Callout type="warn">
459480
**`approverId` is a filter, not authorization.** What you may see is decided

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
*
1111
* The data routes judge the ADDRESSED object. A read that follows a lookup
1212
* reaches an object nobody addressed: the data door's `$expand` served the row
13-
* fields of a target every data route refuses, and the dataset door's label
14-
* passes rendered its display names. Both now ask the target (behaviour-pinned
15-
* in their own packages and on a real stack in `@objectstack/dogfood`). This
13+
* fields of a target every data route refuses, the dataset door's label
14+
* passes rendered its display names, and the import door matched cells against
15+
* it (#22739). Each read classified `decided` below now asks the target
16+
* (behaviour-pinned in its own package). This
1617
* file is the ENUMERATION: it names the census of such reads, holds each
1718
* decided one to the decision, and goes red when a new one appears without
1819
* being classified here.
@@ -36,15 +37,10 @@
3637
* `origin/main` at eae3368a, those are the analytics relationship hop
3738
* (`service-analytics/src/hop-object.ts` — its target comes from the host's
3839
* relationship resolver; the analytics door already judges every hop's object
39-
* for `aggregate` over `queryObjects`); the import door's reference resolution
40-
* (`core/src/utils/import-runner.ts` `resolveRef`, whose target
41-
* `import-field-meta.ts` reads off the raw `reference` carrier — measured: a
42-
* cell's display text is matched against an unexposed target and the matched
43-
* id is stored, a miss is reported per row; open, carried by a follow-up of
44-
* #22661); and the reads whose target is a FIXED platform object rather than
45-
* an authored one (a file field's `sys_file` hydration, the `sys_user` display
46-
* names the approvals and audit surfaces resolve). They are named here, not
47-
* held.
40+
* for `aggregate` over `queryObjects`); and the reads whose target is a FIXED
41+
* platform object rather than an authored one (a file field's `sys_file`
42+
* hydration, the `sys_user` display names the approvals and audit surfaces
43+
* resolve). They are named here, not held.
4844
*
4945
* The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts`
5046
* in this directory: git's authored-file list, never a directory crawl, and `.ts`
@@ -79,7 +75,7 @@ type Classification =
7975
/** The read, in words. */
8076
read: string;
8177
/** The operation the target is judged as. */
82-
operation: 'get';
78+
operation: 'get' | 'list';
8379
/** The function that asks the decision, and the file it lives in. */
8480
decision: { file: string; fn: string };
8581
/** Where the read takes its answer from that function: a call site that must exist. */
@@ -132,6 +128,14 @@ const CALLERS: Record<string, Classification> = {
132128
wiring: { file: 'packages/services/service-analytics/src/analytics-service.ts', call: 'servesLabelTarget(' },
133129
pin: 'packages/services/service-analytics/src/__tests__/dimension-label-exposure.test.ts',
134130
},
131+
'packages/core/src/utils/import-field-meta.ts': {
132+
kind: 'decided',
133+
read: "the import door's reference resolution (`resolveRef` matches a cell against the target; the export and template doors read the same map, the export's expansion decided in protocol.ts)",
134+
operation: 'list',
135+
decision: { file: 'packages/core/src/utils/import-runner.ts', fn: 'servesReferenceTarget' },
136+
wiring: { file: 'packages/core/src/utils/import-runner.ts', call: 'servesReferenceTarget(p, referenceObject' },
137+
pin: 'packages/core/src/utils/import-runner-reference-exposure.test.ts',
138+
},
135139
'packages/objectql/src/relation-filter-lowering.ts': {
136140
kind: 'open',
137141
read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it)",
@@ -288,6 +292,7 @@ describe('[#22661] every read that reaches a second object asks that object its
288292
'servesExpansionTarget',
289293
'servesLabelTarget',
290294
'servesPayloadDisplayTarget',
295+
'servesReferenceTarget',
291296
'servesSummaryTitleTarget',
292297
]);
293298

‎packages/core/src/utils/import-field-meta.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@
1010
* needs. The export renderers stay in `rest`.
1111
*/
1212

13+
import { referenceTargetOf } from '@objectstack/spec/data';
14+
1315
export interface ExportFieldMeta {
1416
name: string;
1517
type?: string;
1618
label?: string;
1719
options?: Array<{ label?: string; value?: unknown; color?: string }>;
18-
/** Target object for lookup / master_detail / user fields. */
20+
/**
21+
* Target object of a field that declares a `reference` string, read through
22+
* the spec's one arbiter `referenceTargetOf` (so a non-reference type names
23+
* none). The import's reference resolution matches cells against it and asks
24+
* it its exposure first (#22739). Only a declared `reference` makes a field
25+
* one: a `user` field written without it keeps no target here, as before.
26+
*/
1927
reference?: string;
2028
/** Field on the referenced record to show as its label. */
2129
displayField?: string;
@@ -79,7 +87,7 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
7987
type: typeof f.type === 'string' ? f.type : undefined,
8088
label: typeof f.label === 'string' ? f.label : undefined,
8189
options: Array.isArray(f.options) ? f.options : undefined,
82-
reference: typeof f.reference === 'string' ? f.reference : undefined,
90+
reference: typeof f.reference === 'string' ? referenceTargetOf(f) : undefined,
8391
displayField: typeof f.displayField === 'string' ? f.displayField : undefined,
8492
multiple: f.multiple === true,
8593
});

0 commit comments

Comments
 (0)