Skip to content

Commit 5124ae0

Browse files
committed
Merge commit 'c3a8e07b6aca321c0114f49944b0564baf0d2c0f' into claude/issue-24583-7a1-driver-null-arm
2 parents 2ee8e8d + c3a8e07 commit 5124ae0

65 files changed

Lines changed: 3541 additions & 270 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: 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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a delegated administrator's position assignment is judged on every permission set the assignment grants, the set of the position's own name included
6+
7+
Clause-②: no
8+
9+
- **The rule (ADR-0090 D12, as amended 2026-10-17).** The sets a position carries are every set an assignment of it grants: the names its definition's `permissionSets` lists and, while the authorization resolver folds held position names into the requested set names, the permission set of the position's own name when the catalog holds one. The organization-level branch already counted that set. The delegated branch now does too, through the same helper.
10+
- **What a delegate is now refused.** A `sys_user_position` insert, update or delete by an `adminScope` holder whose allowlist does not cover the position's own-name set is refused `403 PERMISSION_DENIED`. The message names the set and says it is the set of the position's own name. An own-name set that carries an `adminScope` is held to strict containment like any other distributed set. A delegatable position whose own-name set carries an `adminScope` can no longer be self-delegated.
11+
- **What the delegable-scope report offers.** `GET /api/v1/security/my-delegable-scope` no longer lists a position for a delegate whose allowlist misses that position's own-name set, so the picker offers only what the write admits.
12+
- **Unchanged.** A position with no permission set of its own name is judged on its listed sets alone, as before. Tenant-level administrators and the organization-level branch are untouched, and the resolver's fold itself is not changed. To keep a delegate able to assign such a position, add the own-name set to the scope's `assignablePermissionSets`.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/cli': patch
5+
'@objectstack/service-settings': patch
6+
---
7+
8+
fix(plugin-auth): the auth IP allow-list judges the client address the server established (#24739)
9+
10+
Clause-②: no
11+
12+
The `allowed_ip_ranges` auth setting (ADR-0069 D5) and the `last_login_ip` login audit (D7) now establish a request's client address by the same rule as the inbound rate limiter: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`. Otherwise the address is the connection's peer address.
13+
14+
**For operators who set `allowed_ip_ranges`:**
15+
16+
- Behind a reverse proxy or load balancer, declare `server: { trustProxy: true }` in the stack before you upgrade. Without it, the allow-list judges the proxy's own address for every request.
17+
- A request whose client address cannot be established (the transport reports no peer address, and no forwarded header is trusted) is now refused with `403 IP_NOT_ALLOWED`. Before, it was admitted. The first such refusal logs a warning that names `allowed_ip_ranges` and `server.trustProxy`.
18+
- `CF-Connecting-IP` is no longer read. Behind Cloudflare, declare `server.trustProxy`, and the `X-Forwarded-For` it sets is used.
19+
- A dual-stack listener reports an IPv4 client in IPv4-mapped form (`::ffff:203.0.113.7`). That address is now matched as the IPv4 address it denotes.
20+
- With `allowed_ip_ranges` unset or empty, nothing changes.
21+
22+
**`last_login_ip`** records the address the allow-list judged, or no address when none could be established. It is no longer read from a forwarded header the deployment does not trust.
23+
24+
**By package:**
25+
26+
- `@objectstack/plugin-auth`: the rule is published as the dependency-free `@objectstack/plugin-auth/client-address` entry (`resolveClientAddress`). `AuthPlugin` and `AuthManager` take a `trustProxy` option. `AuthManager.handleRequest` takes an optional second argument that carries the transport's `remoteAddress`.
27+
- `@objectstack/runtime`: the inbound rate limiter imports the rule from that entry instead of keeping its own copy. Its keys are unchanged.
28+
- `@objectstack/cli`: `objectstack serve` passes the stack's `server.trustProxy` to `AuthPlugin` as well as to the dispatcher. A host that composes `AuthPlugin` itself passes `trustProxy` the same way.
29+
- `@objectstack/service-settings`: the `allowed_ip_ranges` help text says which address is judged.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-audit': minor
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
fix(plugin-audit,plugin-auth): a refused sign-in and an account lockout each write a `sys_audit_log` row, as a successful sign-in already does
7+
8+
Clause-②: no
9+
10+
- **Two new ledger actions, each with its writer.** `sys_audit_log.action` gains `login_failed` and `account_locked`. A refused `/sign-in/email` that names an existing account writes one `login_failed` row. The refused sign-in that stamps the account lockout (`lockoutThreshold`, ADR-0069 D2) also writes one `account_locked` row. A sign-in refused while the account is locked writes `login_failed` with the reason `ACCOUNT_LOCKED`. A successful sign-in still writes `login`, unchanged.
11+
- **What each row records.** `user_id` is the account the sign-in named, and the row names `sys_user` and that account's id as its target. The outcome is the action, plus the refusal's machine code in `metadata` (`INVALID_EMAIL_OR_PASSWORD`, `ACCOUNT_LOCKED`, …), and the lock's `locked_until` and `threshold` on an `account_locked` row. `created_at` is the time. `ip_address` and `user_agent` are resolved by the rule better-auth applies to a session row, which is where a `login` row's values come from. `actor` is null, because a refused attempt authenticated nobody. A row never carries the attempted credential, and an identifier that resolves to no account writes no row.
12+
- **Where they show.** Setup → Audit Log's *Auth* preset (the `auth_events` list view) now lists `login`, `logout`, `login_failed` and `account_locked`. Each row is about the account it names, so the ledger's record read gate judges it by that `sys_user` record, as for any row about a user. It carries no `tenant_id`: a refused attempt has no session to take an organization from, so it is a deployment-level row under the ledger's organization scope.
13+
- **Recorded with or without a lockout.** A refused sign-in writes its row whether or not `lockoutThreshold` is set.
14+
- **`@objectstack/plugin-audit` types.** `AuthSessionAuditEvent.action` widens to `AuthEventAuditAction` (`'login' | 'logout' | 'login_failed' | 'account_locked'`). `AuthEventAuditAction` and `AuthAccountAuditAction` are new exported types, and `AuthSessionAuditAction` is unchanged.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Opt-in pre-merge 18.x upgrade harness (#24522, step 0 of the 18.x line).
2+
#
3+
# ## The gap this closes
4+
#
5+
# Ruling record 6266750393 on #15194 makes `main` the release line: every PR on
6+
# the 18.x line, once merged, leaves `main` releasable, and its R2 reads "a
7+
# database an 18.0 install created upgrades to that commit, boots, and keeps
8+
# its data" — a check each of the line's PRs passes BEFORE it merges. The
9+
# harness that answers R2 is a dogfood test,
10+
# `packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts`, and the
11+
# dogfood jobs in `ci.yml` run only on `main`'s full battery (THE TWO SPEEDS,
12+
# #22998): they skip `pull_request` and `merge_group`. So without this workflow
13+
# the line's PRs get their first R2 answer after the merge.
14+
#
15+
# This workflow adds the missing TRIGGER for that ONE file, and nothing else.
16+
# The test, its fixture and its assertions stay where they are, and the full
17+
# battery keeps running the same file with the rest of dogfood on `main`.
18+
#
19+
# ## Opt-in, label-gated — the cheapest wiring measured
20+
#
21+
# Measured on the step-0 branch (the card's report carries the readings):
22+
#
23+
# - the harness file alone: ~54 s of vitest wall (one 4-vCPU box, ~43 s of it
24+
# module import), against the dogfood suite's ~5 min per shard × 3;
25+
# - its build closure is the dogfood package's (`turbo run build
26+
# --filter=@objectstack/dogfood`, 66 tasks), which the turbo cache below
27+
# replays; cold, it is the step's whole cost.
28+
#
29+
# Three shapes were weighed. Running it on every PR taxes the whole repository
30+
# for one release line. Dispatching `ci.yml` by hand on a line PR's branch needs
31+
# no diff but runs the entire full battery (three dogfood shards, Temporal
32+
# Conformance, the console pin) for one file, and is a manual act with no
33+
# trigger a PR carries. The label lane runs one file, only where asked: the
34+
# shape `pack-smoke-optin.yml` already ruled for a pre-merge check that most
35+
# PRs do not need. The epic seat hangs `needs:upgrade-harness` on each 18.x
36+
# line PR; ⛔ never auto-applied, never in `.github/labeler.yml`.
37+
#
38+
# ## Wiring decisions (the `pack-smoke-optin.yml` ones, for the same reasons)
39+
#
40+
# `types:` restates GitHub's defaults beside `labeled` (naming `types:`
41+
# REPLACES the default set). The guard has two limbs: the PR carries the label
42+
# now, and on a `labeled` event the label just added is OURS — otherwise every
43+
# size label would start another run of an unchanged tree. `concurrency` sits at
44+
# JOB level, so a skipped run never cancels a real one. ⛔ No `merge_group:`
45+
# trigger: a `merge_group` event carries no pull request, so the label cannot be
46+
# read there, and this context is NOT a required one
47+
# (`scripts/check-required-contexts.mjs`) — the seat reads it before it queues a
48+
# line PR, the way the ruling's R2 asks.
49+
50+
name: Upgrade Harness (opt-in)
51+
52+
on:
53+
pull_request:
54+
branches:
55+
- main
56+
types: [opened, synchronize, reopened, labeled]
57+
58+
permissions:
59+
contents: read
60+
61+
jobs:
62+
upgrade-harness:
63+
name: 18.x upgrade harness (opt-in)
64+
if: >-
65+
contains(github.event.pull_request.labels.*.name, 'needs:upgrade-harness')
66+
&& (github.event.action != 'labeled' || github.event.label.name == 'needs:upgrade-harness')
67+
runs-on: ubuntu-latest
68+
timeout-minutes: 30
69+
concurrency:
70+
group: upgrade-harness-optin-${{ github.event.pull_request.number }}
71+
cancel-in-progress: true
72+
steps:
73+
# No `ref:` — the MERGE PREVIEW: what `main` will contain once this PR
74+
# lands, which is the commit R2 is a statement about.
75+
- name: Checkout the merge preview
76+
uses: actions/checkout@v7
77+
78+
- name: Setup Node.js
79+
uses: actions/setup-node@v7
80+
with:
81+
node-version: '22'
82+
83+
- name: Setup pnpm
84+
uses: ./.github/actions/setup-pnpm
85+
86+
- name: Get pnpm store directory
87+
shell: bash
88+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
89+
90+
- name: Setup pnpm cache
91+
uses: actions/cache@v6
92+
with:
93+
path: ${{ env.STORE_PATH }}
94+
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
95+
restore-keys: |
96+
${{ runner.os }}-pnpm-store-v3-
97+
98+
# Restore-only: the dogfood job's entries (seeded from `main`) replay this
99+
# closure; a PR never writes the cache, as in ci.yml.
100+
- name: Restore Turbo cache
101+
uses: actions/cache/restore@v6
102+
with:
103+
path: .turbo/cache
104+
key: ${{ runner.os }}-turbo-dogfood-1-main-${{ github.sha }}
105+
restore-keys: |
106+
${{ runner.os }}-turbo-dogfood-1-main-
107+
${{ runner.os }}-turbo-dogfood-
108+
109+
- name: Install dependencies
110+
run: pnpm install --frozen-lockfile
111+
112+
- name: Build the dogfood package's dependency closure
113+
run: pnpm turbo run build --filter=@objectstack/dogfood --concurrency=4
114+
115+
# One file, named: the dogfood config's filter preflight refuses a path
116+
# that selects no test, so a renamed harness reds here instead of
117+
# passing over nothing.
118+
- name: Boot HEAD on the 18.0 fixture (R2) and check the v17 refusal (R3)
119+
run: pnpm --filter @objectstack/dogfood exec vitest run test/upgrade-18x-harness.dogfood.test.ts

‎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/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

0 commit comments

Comments
 (0)