Skip to content

Commit 8cd4023

Browse files
committed
Merge origin/main (cab6396) into claude/issue-20749-test-strings-l
Brings the branch current with main for a full CI run; the merged range touches none of this branch's six test files. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
2 parents 69ea423 + cab6396 commit 8cd4023

34 files changed

Lines changed: 2801 additions & 104 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(plugin-security)!: a predicate-scoped update or delete matches only the rows the caller can read
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered predicate-write-unreadable-row-not-matched -->
11+
12+
**BREAKING**: a predicate-scoped (`multi: true`) update or delete now matches only the rows the caller can read. A row the read door would not return to the caller is not written, not counted and not refused, so a predicate that reaches only such rows answers exactly what a predicate that matches nothing answers: success, zero rows. This is the by-id write doors' rule ("hidden" and "gone" are one answer to a caller who cannot read the row) carried to the predicate door. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed, and no error code is new.
13+
14+
**What changed.** The rows a predicate write matched came from its write scope alone. A row the caller cannot read was matched whenever that scope reached it, for example through a write-class row-level policy wider than the read policy, or on an object whose read visibility follows a parent record. A per-row gate then refused the whole write with a `403`, or the row was written and counted. Either answer told a hidden row apart from no row. The write middleware now asks the read door which rows the caller's own predicate returns, through a read in the caller's context that every data middleware's visibility applies to, and narrows the write to those rows: readable ∩ writable. A read the read door refuses (no read grant on the object) keeps the write's previous answer. A store fault on that read propagates as raised.
15+
16+
**What is refused or narrowed now that was not.**
17+
- A predicate write no longer writes, counts or refuses rows its caller cannot read, including rows its write scope reaches.
18+
- A predicate write whose predicate matches more than 10 000 rows the caller can read is refused with `400 INVALID_FILTER`, before anything is written, rather than narrowed by a cut-off list. The limit is the platform's existing row ceiling for one predicate write.
19+
20+
**FROM → TO.**
21+
- A predicate update or delete reaching rows the caller cannot read: FROM a per-row gate's `403`, or those rows written and counted → TO those rows not matched; success with zero rows when no readable row matches.
22+
- A predicate update or delete whose readable match exceeds 10 000 rows: FROM attempted → TO `400 INVALID_FILTER`, nothing written.
23+
24+
**If you are affected.** An operator who needs a user to change rows grants that user read access to them first. A caller that read a predicate write's `403` as "a row exists here" reads the result as the count of rows it can see. A predicate whose readable match is over the ceiling is narrowed and written in batches.
25+
26+
**Unchanged.**
27+
- A caller who can read a matched row but may not write it keeps its answer.
28+
- Writes the platform issues under the caller's context keep their previous answer, because the caller never addressed them: a cascade, a hook's own write, and the referential clear of a lookup.
29+
- By-id writes keep their answers. System-context writes are not narrowed.
30+
- `security/explain` takes no predicate, so it has no predicate-write verdict to change.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource): `POST /api/v1/datasources/:name/external/validate` sees a federated object saved at runtime, with no restart (#21842)
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** The federation service read its objects from the `metadata` service. That service holds a copy of the engine's object registry taken once at boot. `PUT /api/v1/meta/object/:name`, and the external-table import that saves through it, write `sys_metadata` and the engine registry, but never that copy. So after a federated object was saved at runtime, validate answered the code-defined objects only, and listed the saved one after a restart. An object re-saved at runtime was judged on its definition as it stood at boot.
10+
- **What it reads now.** `ExternalDatasourceServicePlugin` reads objects (`listObjects` and `getObject`) from the engine's object registry on the `objectql` service, which is the registry the save writes through to. The registry is looked up when validation runs, not when the plugin starts. A saved or imported object is listed and judged on what was saved, the moment the save answers.
11+
- **What does not move.** The comparison is unchanged: the same federation predicate, the same column and type checks, and datasource definitions read from the same place. On `objectstack dev` the boot validation gate sweeps the same objects with the same verdicts as before. No route's request or response shape changes, and no export is added.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A flow `approval` node's `config` is judged at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, whole: an undeclared key, a refused value and a required key left out are each refused with a location, in the contract's own words.
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered flow-approval-node-config-contract-refused -->
10+
11+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**Why.** The approval node's executor parses `node.config` against `ApprovalNodeConfigSchema` before it does anything else and fails the node on any issue. Registration already refused an undeclared key, but a refused value such as `escalation.timeoutHours: 0.5` registered and then failed every run that reached the node, and no build door asked about either: `objectstack validate` and `objectstack compile` exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5`, and compile copied it into `dist/objectstack.json`.
14+
15+
**What is refused.** An `approval` node, at any depth, whose `config` the approval contract refuses. The judge is `flowNodeConfigRefusals`, the one `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share; the approval contract joins it as a declared contract map beside the builtin executor contracts, with no plugin loaded. Every issue that contract raises is refused, because the executor refuses on every one:
16+
17+
- an undeclared key, at the key (`nodes.N.config.escalation.bogusKey`, one issue per key, the top level included), and a refused value, at its key (`nodes.N.config.escalation.timeoutHours` for `0.5` under its minimum of 1): the new closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, whose message carries the contract's own sentence — for an alias, its did-you-mean (`timeout` → `timeoutHours`);
18+
- a required key left out (`approvers`; `timeoutHours` inside an `escalation` block): `node-config-key-missing`, as for a builtin node, or `node-config-key-required-by-rule` where a rule of the contract requires it.
19+
20+
The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.<key>`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door (`422 INVALID_METADATA`).
21+
22+
**What stays accepted, byte for byte.** Every approval node the contract accepts, an `approval_revise` node, and every builtin node: the builtin arm still judges only a key left out, so an undeclared key or a wrong-typed value on a builtin node is judged where it was before. A plugin node type whose contract the spec does not declare stays outside the build doors.
23+
24+
## FROM → TO
25+
26+
| you wrote | write instead |
27+
|:--|:--|
28+
| `escalation: { …, bogusKey: 1 }`, or any key the contract does not declare | delete the key, or rename it to the one the refusal's did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → `minApprovals`) |
29+
| `escalation: { timeoutHours: 0.5 }` | `escalation: { timeoutHours: 1 }` — whole wall-clock hours, at least 1 |
30+
| `escalation: { enabled: false }` with no `timeoutHours` | delete the `escalation` block |
31+
| `steps`, `entryCriteria`, `onApprove`, `onReject` or `rejectionBehavior` on the node | the flow graph, as the refusal's guidance says (successive nodes, the entering edge's `condition`, the `approve` / `reject` out-edges, a back-edge) |
32+
| an approval node with no `approvers` | `approvers: [{ type: 'position', value: '<position>' }]` (or any approver the contract accepts) |
33+
34+
**The one-line fix: write the shape the approval contract declares at the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does.
35+
36+
**Who is affected, measured.** At `5e0b489bca`, every approval node `config` authored in this repository parses under the contract: `examples/**` (15 nodes, all in the showcase), `content/docs/**` (6 snippets), `skills/**` (5 snippets) and the `packages/qa/dogfood` fixtures (6 nodes), and so does the Studio designer's approval seed at the pinned objectui commit. Deployed metadata, and repositories other than these two, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register.
37+
38+
### The kit
39+
40+
- **The refusal.** The declared contract map in `automation/flow-node-config-refusals.ts`, read by the same executor-contract arm of `flowNodeConfigRefusals`; the new code joins `FLOW_SLOT_REFUSAL_CODES`.
41+
- **The ledger.** The D3 semantic entry `flow-approval-node-config-contract-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know the approvers, the key or the value the author meant.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
An activity row names its record by the record's title as every renderer resolves it, not by a guess from a fixed list of field names
6+
7+
Clause-②: no
8+
9+
The record-change mirror writes a label for the record into each `sys_activity` row (`record_label`, and inside the created, deleted and generic updated summary). It used to pick that label from a fixed list of field names (`name`, `subject`, `title`, `full_name`, `label`, `first_name`, `company`, `email`) and fall back to the record id. An object titled by any other field showed its record id on every activity row: an object titled by `company_name` read `Created Customer "RECORD_ID"`, with the raw record id, while its record page showed the company name.
10+
11+
The label is now the value of the object's title field as ADR-0079 resolves it (`nameField`, then the deprecated `displayNameField`, then the same derivation the record page, the picker and the approvals inbox use). The record id stays the floor: when nothing resolves, when the title field is the primary key, a credential or a field declared `internal`, or when the record's title value is empty. An empty title no longer borrows another populated field.
12+
13+
- Objects titled by `name`, `title` or `subject` are labelled as before.
14+
- An object whose `nameField` names another field is now labelled by that field. An object whose only list match was not its resolved title is labelled by its title now; in the bundled examples that moves the CRM contact from `full_name` (a formula that declares no `returnType: 'text'`, so it is not derived as the title) to `first_name`, its registered title.
15+
- The activity row records the resolved field as the label's source, so the read-side redaction keeps serving the label only to a reader served that field.
16+
17+
Rows written before this change keep the label they were written with; nothing is backfilled.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ resolve the filter fails closed (deny-all).
6666

6767
Deleting a `sys_attachment` is allowed when the caller is **the uploader** OR
6868
**can edit the parent record** (`sharing.canEdit` — public-model parents are
69-
editable by design). A multi-delete requires *every* matched row to pass.
69+
editable by design). A multi-delete (`multi: true`) matches only attachments
70+
the caller can read: an attachment whose parent is out of the caller's sight is
71+
not matched, not removed and not counted, so a predicate that reaches only such
72+
attachments answers exactly what a predicate that matches nothing answers. Every
73+
matched row must then pass.
7074

7175
| Code | Status | When |
7276
| --- | --- | --- |
@@ -76,7 +80,8 @@ editable by design). A multi-delete requires *every* matched row to pass.
7680

7781
An update of another user's attachment follows the same rule — the uploader or
7882
a parent editor — and a by-id update of an attachment the caller cannot read
79-
gets the same not-found answer.
83+
gets the same not-found answer. A multi-update matches only attachments the
84+
caller can read, as a multi-delete does.
8085

8186
The platform baseline also ships a parent-blind row-level delete floor
8287
(`owner_only_deletes`: you may delete only the rows you created, for members

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ flowchart TD
360360
| 7 | **Field-Level Security** | Which fields is the user allowed to see/edit? |
361361

362362
<Callout type="tip">
363-
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). This keeps security checks efficient even on tables with millions of rows.
363+
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) update or delete matches only the rows the caller can read: a row the read door would not return is not written, not counted and not refused, and above 10 000 such rows the write is refused (`400 INVALID_FILTER`) rather than narrowed by a cut-off list. This keeps security checks efficient even on tables with millions of rows.
364364
</Callout>
365365

366366
## See also

‎content/docs/permissions/rls.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ object) is not narrowed, because their readable set is already unbounded.
8888
modify what you cannot see" is a floor, not a default the write filter
8989
overrides: before a single-record `update` / `delete` runs, the target row is
9090
re-read under the caller's **own read scope** together with the write filter,
91-
and a row that read cannot see is refused with `403 PERMISSION_DENIED`. On a
91+
and a row that read cannot see answers exactly what a missing id answers
92+
(`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) `update` / `delete` is
93+
narrowed the same way: it matches only the rows the caller's read returns for
94+
its predicate, so a row the widener admits but the caller cannot read is not
95+
written and not counted. On a
9296
`public_read` object that read scope is every row (minus any `select`
9397
narrowing you authored), so an `update` widener works as written. On a
9498
**`private`** object — the default OWD — the read scope is the caller's own

‎packages/lint/src/runtime-gate.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ const cleanApprovalFlow = {
4545
type: 'approval',
4646
config: {
4747
approvers: [{ type: 'expression', value: 'current.owner' }],
48-
emptyApproverPolicy: 'reject',
4948
},
5049
},
5150
],

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ const validApprovalFlow = () => {
7070
const flow = brokenApprovalFlow();
7171
flow.nodes[1]!.config = {
7272
approvers: [{ type: 'expression', value: 'current.owner' }],
73-
emptyApproverPolicy: 'reject',
7473
} as any;
7574
return flow;
7675
};

‎packages/objectql/src/plugin.authoring-channel.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,6 @@ describe('#6710 — the authoring channel is threaded from plugin option to prot
274274
const flow = brokenApprovalFlow();
275275
(flow.nodes[1] as any).config = {
276276
approvers: [{ type: 'expression', value: 'current.owner' }],
277-
emptyApproverPolicy: 'reject',
278277
};
279278
const result = await (kernel.getService('protocol') as any).saveMetaItem({
280279
type: 'flow', name: 'leave_approval', item: flow,

0 commit comments

Comments
 (0)