Skip to content

Commit 99f2cfd

Browse files
committed
Merge origin/main 88fb5e8 into claude/issue-20595-metadata-core-citations
Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
2 parents b926d1e + 88fb5e8 commit 99f2cfd

26 files changed

Lines changed: 1633 additions & 323 deletions
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): a view a stored view container expands answers by name what the object door lists, on every kernel and for every container scope
6+
7+
Clause-②: no
8+
9+
- **What changed.** `GET /api/v1/meta/view?object=…` lists the views a stored view container expands, and the by-name read now answers each of those names with the same item. Before, `getMetaItem` expanded no container: it answered such a name only on an unscoped kernel and only for an environment-wide container, where the registry held a hydrated copy. On an environment-scoped kernel, and for an organization-scoped container on any kernel, it answered nothing. Where the name is one a package also ships, such as `<object>.default` under a tenant's overlay of that package's container, it answered the packaged view while the list served the overlay's.
10+
- **How.** The by-name read selects the stored containers in the caller's scope with the list read's own row selection, and expands them with the list read's own expansion. Nothing is persisted or registered, and a stored row of the name itself still answers first.
11+
- **Layers, history and diff for such a name.** `getMetaItemLayered` reports the container's own stored row as `overlay`, with the scope it was read from as `overlayScope`, and the expanded view as `effective`. `historyMetaItem` and `diffMetaItem` answer exactly what they answer under the container's own name, and say so: every event's `ref.name` and the diff's `name` are the container's. No history is made up for a name that was never stored.
12+
- **What does not change.** The container's own name still answers its stored row. The save door is unchanged, including a write by an expanded name. No response shape gains or loses a key.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
Every `sys_user` lookup the approvals plugin writes now holds a user id or nothing: the SLA and dead-run sweeps record no actor instead of a `system:` placeholder, notifications name only the person who acted, and `reassign_from` / `reassign_to` become slot-address text columns; stored placeholders are cleared at the next boot
6+
7+
Clause-②: no
8+
9+
Under ADR-0118 D1 a lookup to `sys_user` holds a user id or null, never a placeholder value. Four writers broke that, and a lookup holding a non-id drops the row from every join and report on it, silently.
10+
11+
**This supersedes the "The SLA sweep keeps its reserved `system:sla` actor for now" sentence of the unreleased `21411-approval-actor-person` changeset.** Both ship in one release; from it, the sweep records no actor.
12+
13+
- **Machine actors record no actor.**
14+
- The SLA sweep's `escalate` row, and the `approve` / `reject` an `auto_approve` / `auto_reject` escalation then records, have `actor_id` empty. Before, both held `system:sla`. The `escalate` row's comment still names the configured action.
15+
- The dead-run sweep's `recall` row has `actor_id` empty. Before, it held `system:dead-run`. Its comment still names the dead run and its status, and a submitter's own recall still records the submitter.
16+
- **Notifications name only a person.** The actor the plugin hands to `sys_notification.actor_id` (and so to each `sys_inbox_message.actor_id`) is the user the action's context vouches for, or nothing.
17+
- Before, a reassign, reminder, request for information, comment or send-back taken under a named position or email forwarded that address as the actor.
18+
- Before, every SLA notification forwarded `system:sla`. It now forwards no actor, as the out-of-office notifications already did.
19+
- **`reassign_from` / `reassign_to` are slot addresses.** A reassignment moves a pending-approver slot, so both columns hold the slot's address in its stored spelling: a user id, an email, or `position:<name>`. They are now text columns (max 255 characters, like `acted_as`) instead of `sys_user` lookups, which matches what they already stored.
20+
- Existing values need no rewrite, and an existing database keeps its columns as they are. On SQLite and PostgreSQL 16, booting the new declaration over a table created by the old one issues no DDL, keeps every stored value, and reports no schema drift for either column. A new database creates them as `text`, as it does `acted_as`.
21+
- The action log still resolves `reassign_from_name` / `reassign_to_name` where an address names an account: a user id, or an email an account carries. A position address has no name.
22+
- **Stored rows.** The boot-time repair that moves slot literals out of `actor_id` now also clears `system:sla` and `system:dead-run` from it, in the same pass and in the same idempotent way. A cleared sentinel gets no `acted_as`, because a sweep takes no slot. The boot log line reports the count as `sentinelsCleared`.
23+
- **For a report or integration that read these values:**
24+
- To find the SLA sweep's actions, read the `escalate` rows, and the decision that directly follows an `escalate` row whose comment names `auto_approve` or `auto_reject`. Do not test `actor_id` for `system:sla`.
25+
- To find a dead-run release, read the `recall` row whose comment names the run. Do not test `actor_id` for `system:dead-run`.
26+
- Read `reassign_from` / `reassign_to` as slot addresses. Do not expand them as `sys_user` references.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/verify": patch
3+
---
4+
5+
`bootStack` (and so `os verify`) no longer creates a data key file in the key home, and no longer seals its fixtures under a key the host already holds (#21499)
6+
7+
Clause-②: no
8+
9+
The harness composed the settings service with no crypto provider and bound the engine to a default `LocalCryptoProvider`. `bootStack` forces a development posture. In that posture, with no `OS_SECRET_KEY`, no `OS_DEV_CRYPTO_KEY` and no key file, both providers wrote a new key file into the key home. So `os verify`, a one-shot command over an in-memory database, left key material behind, and the next development-posture process on that host adopted it. On a host that already had a key, the harness sealed its throwaway fixtures under that real key.
10+
11+
- **What the harness uses now.** One `LocalCryptoProvider` over a random key held in this process's memory only. It never reads `OS_SECRET_KEY`, `OS_DEV_CRYPTO_KEY` or the key file, and it never writes anywhere. The settings service and the engine get the same instance, so `secret` fields and encrypted settings still seal and open on a host with no key at all.
12+
- **One key per process, not per boot.** Two `bootStack` calls over one `databaseFile` in the same process (the harness's restart) still open each other's secrets.
13+
- **Unchanged.** `BootOptions` and the rest of the public API, and `os verify`'s stdout and `--json` report. The one stderr line announcing the minted key file is gone.

‎content/docs/automation/approvals.mdx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,9 @@ not a pending approver`); a request that isn't pending returns 409
512512
(`INVALID_STATE`). The decision records two facts on its `sys_approval_action`
513513
row: `actor_id` is the user who decided, and `acted_as` is the slot the decision
514514
took, in that slot's stored spelling — the multi-approver tally counts approvals
515-
by matching `acted_as` against the slate. Always go through
515+
by matching `acted_as` against the slate. A `reassign` row records the slot it
516+
moved the same way: `reassign_from` and `reassign_to` hold slot addresses (a
517+
user id, an email or a position address), not users. Always go through
516518
these endpoints — never resume the flow run directly, and since #3801 you
517519
**cannot**: `POST /api/v1/automation/{flow}/runs/{runId}/resume` answers 403 for
518520
a run parked on an `approval` node (including via a `subflow` pause) and changes
@@ -672,8 +674,9 @@ trace that a slate had been bypassed was the designated approver's later
672674
reaches a terminal state without a decision — it failed, was cancelled, timed
673675
out, or the process hosting it crashed — nothing is left to decide the request,
674676
so a periodic sweep finalizes it as `recalled` and releases the record. The
675-
audit row records the actor `system:dead-run` and names the run and its status,
676-
so it reads distinctly from a submitter's own recall.
677+
audit row records no actor — a sweep is not a user, so `actor_id` is empty,
678+
never a placeholder value (ADR-0118 D1) — and names the run and its status, so
679+
it reads distinctly from a submitter's own recall, which records the submitter.
677680

678681
The sweep only ever acts on a run it can positively confirm is terminal: a
679682
paused run (the normal state of a live approval), an unknown run, or an
@@ -724,7 +727,10 @@ that request's drawer directly instead of a generic list.
724727
`escalation` is real, not decorative: set `enabled: true` and `timeoutHours`,
725728
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
726729
`auto_reject`. Auto decisions run through the normal decide path, so the flow
727-
resumes exactly as if a human had clicked. Every escalation writes an audit row.
730+
resumes exactly as if a human had clicked. Every escalation writes an audit row
731+
of kind `escalate` naming the configured action; it, the auto decision that
732+
follows it, and the escalation's notifications record no actor, because no
733+
person acted (ADR-0118 D1).
728734
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
729735
holidays included, because the platform ships no business-hours calendar — so a
730736
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00

‎packages/metadata-protocol/src/get-meta-item-layered-org-read-gate.test.ts‎

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -97,33 +97,40 @@ const storedRow = (
9797
* The engine double: `findOne` over a row table, plus the registry surface the
9898
* layered read touches on its way past the overlay.
9999
*
100-
* ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path
101-
* under test issues exactly one verb, and a double declaring verbs no case
102-
* exercises would owe `check:engine-double-contract` a dispatch contract that
103-
* protects nothing. Same shape the two sibling read-gate pins drive.
100+
* `find` is the second verb the read path issues, and only for a `view` name
101+
* with no stored row of its own: the read then selects the stored view
102+
* containers that might expand that name, through the list read's own row
103+
* selection (#21442). It records into `finds` — the same partitions question
104+
* asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a
105+
* double declaring verbs no case exercises would owe
106+
* `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the two sibling read-gate pins drive.
104107
*/
105108
function makeHarness(rows: StoredRow[]) {
106109
const findOnes: Array<Record<string, unknown>> = [];
110+
const finds: Array<Record<string, unknown>> = [];
111+
const matching = (where: Record<string, unknown>) => {
112+
// `check:where-matcher` — a hand-written matcher with no combinator
113+
// branch reads `$and` as a field name and answers the wrong question
114+
// rather than failing. Refuse the shape this double does not
115+
// implement, matching the sibling doubles' convention.
116+
for (const k of Object.keys(where)) {
117+
if (k.startsWith('$')) {
118+
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
119+
}
120+
}
121+
return rows.filter((r) =>
122+
Object.entries(where).every(([k, v]) => {
123+
if (v === undefined) return true;
124+
return (r as unknown as Record<string, unknown>)[k] === v;
125+
}),
126+
);
127+
};
107128
const engine: any = {
108129
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
109130
if (table !== 'sys_metadata') return undefined;
110131
const where = opts?.where ?? {};
111132
findOnes.push({ ...where });
112-
// `check:where-matcher` — a hand-written matcher with no combinator
113-
// branch reads `$and` as a field name and answers the wrong
114-
// question rather than failing. Refuse the shape this double does
115-
// not implement, matching the sibling doubles' convention.
116-
for (const k of Object.keys(where)) {
117-
if (k.startsWith('$')) {
118-
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
119-
}
120-
}
121-
return rows.find((r) =>
122-
Object.entries(where).every(([k, v]) => {
123-
if (v === undefined) return true;
124-
return (r as unknown as Record<string, unknown>)[k] === v;
125-
}),
126-
);
133+
return matching(where)[0];
127134
},
128135
registry: {
129136
registerItem: () => undefined,
@@ -137,8 +144,14 @@ function makeHarness(rows: StoredRow[]) {
137144
applyNavContributions: (app: unknown) => app,
138145
},
139146
};
147+
engine.find = async (table: string, opts?: { where?: Record<string, unknown> }) => {
148+
if (table !== 'sys_metadata') return [];
149+
const where = opts?.where ?? {};
150+
finds.push({ ...where });
151+
return matching(where);
152+
};
140153
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
141-
return { protocol, findOnes };
154+
return { protocol, findOnes, finds };
142155
}
143156

144157
/** Every `organization_id` partition the engine was asked for, deduplicated. */
@@ -443,11 +456,14 @@ describe('§5 an already-gating caller receives the same scope it did before', (
443456
// for `view` gets the org partition read, exactly as before.
444457
const gated = organizationIdForMetaRead('view', ORG);
445458
expect(gated).toBe(ORG);
446-
const { protocol, findOnes } = makeHarness([]);
459+
const { protocol, findOnes, finds } = makeHarness([]);
447460
await protocol.getMetaItemLayered({
448461
type: 'view', name: 'probe', organizationId: gated,
449462
});
450463
expect(partitions(findOnes)).toEqual([null, ORG]);
464+
// [#21442] `probe` has no row of its own, so the read also selects the
465+
// stored containers that might expand it — from the same partitions.
466+
expect(partitions(finds)).toEqual([null, ORG]);
451467
});
452468
});
453469

‎packages/metadata-protocol/src/get-meta-item-org-read-gate.test.ts‎

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -109,33 +109,40 @@ const storedRow = (
109109
* The engine double: `findOne` over a row table, plus the registry surface the
110110
* single-item read path touches on its way past the overlay.
111111
*
112-
* ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path
113-
* under test issues exactly one verb, and a double declaring verbs no case
114-
* exercises would owe `check:engine-double-contract` a dispatch contract that
115-
* protects nothing. Same shape the sibling plural-door pin drives.
112+
* `find` is the second verb the read path issues, and only for a `view` name
113+
* with no stored row of its own: the read then selects the stored view
114+
* containers that might expand that name, through the list read's own row
115+
* selection (#21442). It records into `finds` — the same partitions question
116+
* asked of that read. ⛔ No `insert` / `update` / `delete`, deliberately — a
117+
* double declaring verbs no case exercises would owe
118+
* `check:engine-double-contract` a dispatch contract that protects nothing. Same shape the sibling plural-door pin drives.
116119
*/
117120
function makeHarness(rows: StoredRow[]) {
118121
const findOnes: Array<Record<string, unknown>> = [];
122+
const finds: Array<Record<string, unknown>> = [];
123+
const matching = (where: Record<string, unknown>) => {
124+
// `check:where-matcher` — a hand-written matcher with no combinator
125+
// branch reads `$and` as a field name and answers the wrong question
126+
// rather than failing. Refuse the shape this double does not
127+
// implement, matching the sibling doubles' convention.
128+
for (const k of Object.keys(where)) {
129+
if (k.startsWith('$')) {
130+
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
131+
}
132+
}
133+
return rows.filter((r) =>
134+
Object.entries(where).every(([k, v]) => {
135+
if (v === undefined) return true;
136+
return (r as unknown as Record<string, unknown>)[k] === v;
137+
}),
138+
);
139+
};
119140
const engine: any = {
120141
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
121142
if (table !== 'sys_metadata') return undefined;
122143
const where = opts?.where ?? {};
123144
findOnes.push({ ...where });
124-
// `check:where-matcher` — a hand-written matcher with no combinator
125-
// branch reads `$and` as a field name and answers the wrong
126-
// question rather than failing. Refuse the shape this double does
127-
// not implement, matching the sibling doubles' convention.
128-
for (const k of Object.keys(where)) {
129-
if (k.startsWith('$')) {
130-
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
131-
}
132-
}
133-
return rows.find((r) =>
134-
Object.entries(where).every(([k, v]) => {
135-
if (v === undefined) return true;
136-
return (r as unknown as Record<string, unknown>)[k] === v;
137-
}),
138-
);
145+
return matching(where)[0];
139146
},
140147
registry: {
141148
registerItem: () => undefined,
@@ -149,8 +156,14 @@ function makeHarness(rows: StoredRow[]) {
149156
applyNavContributions: (app: unknown) => app,
150157
},
151158
};
159+
engine.find = async (table: string, opts?: { where?: Record<string, unknown> }) => {
160+
if (table !== 'sys_metadata') return [];
161+
const where = opts?.where ?? {};
162+
finds.push({ ...where });
163+
return matching(where);
164+
};
152165
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
153-
return { protocol, findOnes };
166+
return { protocol, findOnes, finds };
154167
}
155168

156169
/** Every `organization_id` partition the engine was asked for, deduplicated. */
@@ -389,9 +402,12 @@ describe('§4 an already-gating caller receives the same scope it did before', (
389402
// the org partition read, exactly as before this change.
390403
const gated = organizationIdForMetaRead('view', ORG);
391404
expect(gated).toBe(ORG);
392-
const { protocol, findOnes } = makeHarness([]);
405+
const { protocol, findOnes, finds } = makeHarness([]);
393406
await protocol.getMetaItem({ type: 'view', name: 'probe', organizationId: gated });
394407
expect(partitions(findOnes)).toEqual([null, ORG]);
408+
// [#21442] `probe` has no row of its own, so the read also selects the
409+
// stored containers that might expand it — from the same partitions.
410+
expect(partitions(finds)).toEqual([null, ORG]);
395411
});
396412
});
397413

0 commit comments

Comments
 (0)