Skip to content

Commit 65cdcf2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24750-invite-role-not-found-code
2 parents 3810c7f + 08d201a commit 65cdcf2

34 files changed

Lines changed: 1996 additions & 131 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(lint)!: a `lookup` or `master_detail` field whose `reference` is a stored-metadata-body table is refused where it is declared
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape or authorable spelling is added, removed or renamed. The expression rule's object pass now refuses one field declaration it accepted before: a `lookup` or `master_detail` whose `reference` is `sys_metadata` or `sys_metadata_history`, which the engine never hydrates. Nothing stored is rewritten, so there is nothing for `objectstack migrate meta` to convert; the `@objectstack/spec` change is the text of the `reference` describe only. -->
11+
12+
**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
A `lookup` or `master_detail` whose `reference` names a table of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`: `sys_metadata`, `sys_metadata_history`) is an error. The engine never hydrates such a field: `$expand` keeps the stored id and never nests the row, a formula or a validation rule never reads through it, and a master-detail child's `parent` root stays unbound, so every write a `parent` rule judges is refused. `os validate`, `os build` and the object save door (`PUT /api/v1/meta/object/:name`, and a draft's promotion) run the same pass, under the existing `EXPRESSION_INVALID` rule, located at the field's `reference`.
15+
16+
- FROM: `meta: { type: 'lookup', reference: 'sys_metadata' }` (or a `master_detail`, or `sys_metadata_history`) saved with a 200 and built with exit 0, and then only ever showed an id. TO: refused, with `422 INVALID_METADATA` at the save door and an error at `os validate` / `os build`. The message names the field, its type and the target, and ends on the family's one prescription.
17+
- A reference to any other table, platform tables such as `sys_user` included, is unchanged.
18+
- A `user` field whose authored `reference` names a family table is refused the same way: the parse accepts an authored `reference` on a `user` field, and it is the target every reader resolves.
19+
- The refusal of a formula or a validation rule that reads through such a field is unchanged, and is still reported at that expression.
20+
- A stored object row that already declares such a field keeps loading: no authoring rule runs when a stored row is rehydrated, and the engine guards keep refusing every relationship read into the family. Its next active save, or a draft's promotion, through the metadata API is refused until the field is removed or retargeted. The engine guards stay, for every path that runs no authoring rule.
21+
- `@objectstack/spec` (`patch`): the `reference` describe states the convention, so the generated field reference says it too.
22+
23+
What to do. Remove the field, or retarget it. To point a record at a metadata item, store the item's type and name in `text` fields and read the item through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
---
4+
5+
feat(plugin-approvals): the decision that ends an approval tells its submitter the outcome (`approval.approved` / `approval.rejected`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What was wrong.** Approving or rejecting a request notified nobody. A send-back (`approval.returned`) and a question (`approval.request_info`) reached the submitter, but the decision itself did not, so a submitter learned that their request was approved or rejected only by opening the Approvals Inbox.
10+
- **What is new.** When a decision ends the approval, the submitter of the request it decides gets one notification on one of two new topics: `approval.approved` (title "Request approved") or `approval.rejected` (title "Request rejected"). The body names the record by its object's label and its id, as every approval notification does (`Your Deal D_123 was rejected.`), followed by the decider's comment as its own paragraph when they wrote one. It links to that request in the Approvals Inbox and, like the other approval notices, carries as its actor the person the decision's context vouches for. It goes through the installed `messaging` service like every other approval topic, so the same channels and delivery preferences apply; a preference row can mute one outcome and keep the other.
11+
- **When "ends the approval" holds.** A co-sign vote that leaves a `unanimous`, `quorum` or `per_group` request pending tells no one. A decision whose flow moves on to another approval step tells no one either; the decision that ends the run is the one that notifies. That is read from the run after the decision resumes it, not from the flow's graph, so when a decision node routes around a later step, the first decision is the outcome.
12+
- **Who is not told.** A submitter who decided their own request. And an SLA escalation's `auto_approve` or `auto_reject`: the escalation's own submitter notice (`approval.sla_breached`, naming the action taken) is the one message about it, so `escalation.notifySubmitter: false` still means the submitter hears nothing. A decision whose flow run cannot be resumed reports its failure to the decider and tells the submitter nothing, as a send-back in the same state already did.
13+
- **What to change in your flows.** If a flow ends with a `notify` node whose only job is to tell the submitter the decision, remove it, or the submitter is told twice. `notify` nodes that address anyone else are unaffected. No flow in this repository's examples has that workaround: the three showcase flows whose `notify` node reports a decision address someone other than the submitter — `showcase_invoice_signoff` and `showcase_project_closure` the record's owner, `showcase_release_signoff` a fixed address — so they are unchanged, and only a record owner who is also the person who submitted now hears both.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): under the `group` posture, a tenant-scoped insert from a session with no active organization is refused 403 naming the missing organization, as `isolated` already answered, instead of a 500
6+
7+
Clause-②: no (no accept set moves: the insert was refused before and is refused now, with the ADR-0123 D2 answer in place of an internal error)
8+
9+
- **Before.** On `group` (any union-scope posture), a member of an organization whose session had no active organization got `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` for a create on a tenant-scoped object, logged as an unhandled REST error.
10+
- **After.** That create gets `403 PERMISSION_DENIED` with the message `isolated` gives the same session, which names the missing active organization and tells the user to join or select one.
11+
- **Unchanged.** Nothing was written before and nothing is written now. The refusal judges the row as it is about to be stored, the same row the engine's own organization check reads, so it covers exactly the inserts that answered 500 and no insert that was admitted. Reads still span the member's organizations. Updates and deletes of rows the membership wall admits, inserts from a session with an active organization, system contexts, a platform operator on a posture-permitting object, the delegator's leg, and every `isolated` and `single` answer are as before.

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
>
1212
> 本体即软件。
1313
>
14+
> Ontology-driven: database, API, UI and agent tools all derive from that one
15+
> definition of the business, so there is no second application to keep in step
16+
> with it.
17+
>
1418
> Apps small enough for AI to hold whole.
1519
1620
`Executable` · `AI-writable` · `Agent-operable` · `You own it` · Apache-2.0
@@ -90,6 +94,13 @@ runtime, as hooks, action bodies, CEL, and constrained JSX. The full account is
9094
[Business Ontology](https://objectstack.ai/docs/concepts/ontology); the short
9195
form is the [glossary entry](https://objectstack.ai/docs/getting-started/glossary#business-ontology).
9296

97+
Metadata-driven, as the previous generation of platforms used the word, names
98+
the mechanism — behaviour derived from a declarative definition instead of
99+
written as code — and ObjectStack keeps it. Calling the stack ontology-driven
100+
names what that definition is and whose it is: a definition of the business
101+
rather than configuration of a platform, an open format in your repository
102+
rather than inside a vendor's system, and written for an agent to read.
103+
93104
[How it compares](https://objectstack.ai/docs/concepts/compare) sets ObjectStack
94105
beside knowledge ontologies, semantic layers, operational ontologies, metadata
95106
platforms, and AI code generation. [When to use it](https://objectstack.ai/docs/concepts/when-to-use)

‎content/docs/automation/approvals.mdx‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -536,8 +536,35 @@ second one for the opening.
536536

537537
If a flow has a `notify` node beside its approval node only to tell the
538538
approvers that a request is waiting, remove that node: those approvers are now
539-
told twice. Keep `notify` nodes that address someone else, such as the
540-
submitter or a watcher, or that report the decision.
539+
told twice. Keep `notify` nodes that address someone else, such as a watcher,
540+
or that report the decision to anyone but the submitter.
541+
</Callout>
542+
543+
<Callout type="info">
544+
**The decision tells the submitter.** When a decision ends the approval, the
545+
submitter of the request it decides gets one notification: `approval.approved`
546+
("Request approved") or `approval.rejected` ("Request rejected"). Its body
547+
names the record (`Deal D_123`) and the outcome, followed by the decider's
548+
comment when they wrote one, and it links to that request in the Approvals
549+
Inbox. There is one topic per outcome, so a delivery preference can mute
550+
approvals and keep rejections.
551+
552+
"Ends the approval" means the process, not the step. A co-sign vote that
553+
leaves a `unanimous`, `quorum` or `per_group` request pending tells no one,
554+
and neither does a decision whose flow moves on to another approval step: on
555+
a two-step flow the first step's decision is silent and the decision that ends
556+
the run is the one that notifies. Whether a later step opened is read from the
557+
run, not from the flow's graph, so when a decision node routes around the
558+
later step, the first decision is the outcome and notifies.
559+
560+
Two cases tell nobody by design. A submitter who decided their own request
561+
already knows. An SLA `auto_approve` or `auto_reject` is told by the
562+
escalation's own submitter notice (`approval.sla_breached`, which names the
563+
action taken), so `notifySubmitter: false` still means the submitter hears
564+
nothing about it.
565+
566+
If a flow ends with a `notify` node whose only job is to tell the submitter
567+
the decision, remove that node: the submitter is now told twice.
541568
</Callout>
542569

543570
### Mount the approvals inbox in your app

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ Reference to a record in another object (foreign key).
307307

308308
| Property | Type | Default | Description |
309309
|:---|:---|:---|:---|
310-
| `reference` | `string` | **required** | Target object name (snake_case) |
310+
| `reference` | `string` | **required** | Target object name (snake_case). A stored-metadata table (`sys_metadata`, `sys_metadata_history`) is refused — see [below](#a-relationship-never-targets-a-stored-metadata-table) |
311311
| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property. `FieldSchema` is a strict object, so an unknown key is **rejected with guidance**, not silently stripped (ADR-0078): the error echoes the offending key and prescribes the replacement. Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) |
312312
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**. On a `multiple: true` required lookup the escalation is judged per referencing row: only a row the member removal would leave EMPTY is refused. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` |
313313

@@ -329,7 +329,7 @@ Parent-child relationship (cascading delete by default).
329329

330330
| Property | Type | Default | Description |
331331
|:---|:---|:---|:---|
332-
| `reference` | `string` | **required** | Target (master) object name |
332+
| `reference` | `string` | **required** | Target (master) object name. A stored-metadata table is refused — see [below](#a-relationship-never-targets-a-stored-metadata-table) |
333333
| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property. `FieldSchema` is a strict object, so an unknown key is **rejected with guidance**, not silently stripped (ADR-0078): the error echoes the offending key and prescribes the replacement. Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) |
334334
| `deleteBehavior` | `'restrict' \| 'cascade'` | `'cascade'` | Behavior when parent is deleted. `restrict` refuses the parent delete while children exist; `cascade` (and omitting the key) deletes the children with the parent. An explicit `set_null` is a **parse-time rejection** on this type (#9689) — a detail row cannot outlive its master; use a `lookup` if children must survive the parent |
335335
| `inlineEdit` | `boolean \| 'grid' \| 'form'` | — | Edit child records inline on the parent create/edit form (`true` = auto-pick, `'grid'`, or `'form'`) |
@@ -347,6 +347,10 @@ Parent-child relationship (cascading delete by default).
347347
}
348348
```
349349

350+
#### A relationship never targets a stored-metadata table
351+
352+
A `lookup` or `master_detail` whose `reference` is `sys_metadata` or `sys_metadata_history` is refused by `os validate`, `os build` and the object save door alike. Those rows carry serialized metadata bodies, which are served only through the metadata API (`GET /api/v1/meta/:type/:name`), where the body is projected; the engine never hydrates a reference into them, so `$expand` would keep the stored id and never nest the row, and a master-detail child's `parent` root would stay unbound, refusing every write a `parent` rule judges. To point a record at a metadata item, store its type and name in `text` fields and read the item through the metadata API.
353+
350354
### `tree`
351355
Self-referential hierarchy (e.g., categories, org chart).
352356

‎content/docs/references/data/field.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const result = CurrencyConfigSchema.parse(data);
7676
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
7777
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. |
7878
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. |
79-
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. |
79+
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. |
8080
| **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
8181
| **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. |
8282
| **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. |

0 commit comments

Comments
 (0)