Skip to content

Commit 0313c76

Browse files
committed
Merge origin/main (607463d..cab6396) into claude/issue-21863-stale-reconciliation-rows
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
2 parents bff24d6 + cab6396 commit 0313c76

87 files changed

Lines changed: 3690 additions & 392 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: 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: 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+
Global search (`GET /api/v1/search`) skips the objects a caller cannot read instead of failing the whole request with `403 PERMISSION_DENIED` (#21836).
6+
7+
Clause-②: no
8+
9+
- **Object level.** Before an object is queried, `searchAll` asks the `security` service's `canReadObject` with the caller's context, the same read gate the engine middleware enforces. An object it refuses is skipped. A member whose scope included any object they hold no read grant on used to get 403 for every query. The console palette then showed "No results found." with no error.
10+
- **Field level.** Each object is searched only on the fields the caller may query (`getQueryableFields`). The engine refuses a search that would match on a hidden field, and `sys_user`'s searchable fields include admin-only columns, so a member's unscoped search hit that refusal as well. An object left with no queryable search field is skipped.
11+
- **Nothing about a skipped object reaches the response.** It is not queried, named or counted in `totalObjects`, and the decision is made before any row is read. An explicit `objects=` naming an unreadable object gets the same answer as a name that matches no object.
12+
- **Other failures still fail the search.** A read error on a readable object, or an admission check that throws, propagates as before. Callers that can read every object, and calls without a context, get the same answer as before.
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
fix(platform-objects)!: retire the `sys_account` `link_social` action, which was dead on every boot; `unlink_account` stays
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) The withdrawn action is platform-shipped metadata on sys_account, not something an author writes: no spec key, spelling, export name or config field is retired or renamed, and nothing an author wrote needs rewriting. No stored shape carries it either: sys_account is lock full, so no sys_metadata overlay of the object can be saved, and an action is not a stored row. No reachable member of an exported type carries it, measured on the built declarations: SysAccount's declared type keeps only fields from its literal, so its action names already resolved to string, and the bundles are typed as TranslationData objects with no key types. -->
10+
11+
**BREAKING**: `sys_account` no longer declares the `link_social` action, so the "Link Social Account" toolbar button is gone from the Account app's Linked Accounts list and from Setup's Identity Links. It never completed a link on any boot: it navigated to a `GET` of the social sign-in route, which is served as `POST` only, and it offered a fixed list of seven providers whatever the boot had configured. It is retired under ADR-0049 (enforce or remove) and ships as `minor` under the launch-window convention for narrowings.
12+
13+
**What stays.** `unlink_account` is unchanged: the same type, target, placement and row-id parameter. Its confirm question no longer says the user can re-link "from their account settings", because no console surface offers that now. The `sys_account._actions.link_social` leaves are gone from the `en`, `zh-CN`, `ja-JP` and `es-ES` bundles.
14+
15+
**What to do after upgrading.** Linking a social or OIDC identity stays available through the signed-in `POST /api/v1/auth/link-social`, which is `auth.accounts.linkSocial({ provider, callbackURL })` in `@objectstack/client`: call it and navigate to the `url` it answers.
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

‎content/docs/protocol/objectui/actions.mdx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,15 @@ label: View Dashboard
9494
type: url
9595
target: /_console/dashboards/sales_overview
9696
97-
name: link_social
98-
label: Connect Account
97+
name: open_in_maps
98+
label: Open in Maps
9999
type: url
100-
target: '/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account'
100+
target: 'https://www.google.com/maps/search/?api=1&query=${param.address}'
101+
params:
102+
- name: address
103+
label: Address
104+
type: text
105+
required: true
101106
```
102107

103108
For a **static** url, `openIn: 'new-tab'` opens `target` in a new tab and `openIn: 'self'` navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an **async handler that redirects**, set `opensInNewTab: true` instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); `newTabUrl` provides a zero-roundtrip new-tab target template supporting the `{recordId}` placeholder.

0 commit comments

Comments
 (0)