Skip to content

docs(skills): date-bucket engine sentences name what each arm emits - #21677

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-21588-dashboards-date-bucket-engine-sentences
Oct 4, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-21588-dashboards-date-bucket-engine-sentences

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21588

Clause-②: no

Three sentences in two published skills describe the date-bucket engine, and each one named something the runtime does not do. This PR rewrites exactly those three sentences, read arm by arm from the driver code on origin/main at 55e6f14f8d, and nothing else in either file.

File Line Before After
skills/objectstack-ui/rules/dashboards.md 321 'week' row: "ISO date of the bucket (YYYY-MM-DD)" "ISO week YYYY-Www"
skills/objectstack-ui/rules/dashboards.md 326–328 "Postgres date_trunc, MySQL date_format, SQLite strftime, MongoDB $dateTrunc, in-memory fallback. All emitted by the analytics service, not the client." "drivers emit the bucket as a label (2026-01), not an instant: Postgres to_char, MySQL date_format, SQLite strftime, MongoDB $dateToString, in-memory bucketDateKey."
skills/objectstack-query/rules/aggregation.md 82–85 "pushes bucketing down to the driver (DATE_TRUNC etc.)" "pushes bucketing down to the driver (to_char / date_format / strftime / $dateToString, never date_trunc)" — the push-down / in-memory-fallback clause and "including the column keys" are kept as they were

The ruling on the card (triage comment 5969875145) fixed the engine sentence and said no other sentence in dashboards.md moves; the engine seat's carrier addition (5973137901) measured the 'week' row and the aggregation sentence as the same family, and the dispatching seat folded all three into this one governed PR (claim comment 5975736098).

Reading 1 — what each arm emits, read from the code

Every arm answers a string label, never a truncated instant; the week label is the ISO week YYYY-Www on all five.

Arm Where Expression emitted Example keys
PostgreSQL packages/drivers/driver-sql/src/sql-driver.ts:6161–6169 (buildDateBucketExpr) to_char((col)::timestamptz AT TIME ZONE 'UTC', FORMAT) for a datetime column, to_char((col)::date::timestamp, FORMAT) for a Field.date; formats YYYY, YYYY-MM, YYYY-MM-DD, YYYY"-Q"Q, IYYY"-W"IW 2026-01, 2026-Q1, 2026-W23
MySQL sql-driver.ts:6172–6180 date_format(convert_tz(col, @@session.time_zone, '+00:00'), FORMAT) (bare col for a Field.date); %Y, %Y-%m, %Y-%m-%d, %x-W%v; quarter is concat(date_format(…, '%Y'), '-Q', quarter(…)) 2026-01, 2026-W23
SQLite sql-driver.ts:6183–6208 strftime(FORMAT, ARG) with %Y, %Y-%m, %Y-%m-%d; quarter from %Y and (%m - 1) / 3 + 1; week by the Thursday rule, strftime('%Y', ARG, '-3 days', 'weekday 4') || '-W' || printf('%02d', (cast(strftime('%j', ARG, '-3 days', 'weekday 4') as integer) - 1) / 7 + 1) (PR #21629, merged, on origin/main) 2026-01, 2026-W23
MongoDB packages/drivers/driver-mongodb/src/mongodb-aggregation.ts:246–275 { $dateToString: { format, date: { $convert: { input: '$FIELD', to: 'date', onError: null, onNull: null } } } } with %Y, %Y-%m, %Y-%m-%d, %G-W%V; quarter is $concat of %Y, -Q and a $switch over %m. The docblock at :194 is headed "Labels, not instants — and therefore no $dateTrunc" 2026-01, 2026-W23
In-memory packages/core/src/utils/datetime.ts:313 bucketDateKey (week via isoWeekLabelFromCalendarDay, :389); the engine's fallback packages/objectql/src/in-memory-aggregation.ts:375 delegates to it, and packages/objectql/src/engine.ts:17615 picks push-down vs fallback from supports.queryDateGranularity string keys YEAR, YEAR-MM, YEAR-MM-DD, YEAR-Qn, ISOYEAR-Www built from the calendar parts in the reference zone — the writer the drivers' expressions are held equal to (checkDateBucketParity) 2026-01, 2026-W23

The rendered dashboard label is the key: packages/services/service-analytics/src/dimension-labels.ts:321–333 formatDateBucket returns a key the writer wrote at that granularity as written (bucketKeyToCalendarRange(value, granularity) !== null), and src/__tests__/dataset-granularity-postprocess.test.ts:66 pins week: '2026-W29' through queryDataset. So the 'week' row's "ISO date of the bucket (YYYY-MM-DD)" was wrong on every face, and the engine sentence named two expressions no arm emits.

Reading 2 — token ratchet and line count, before / after

node scripts/check-skills-token-ratchet.mjs (ceil(utf8 bytes / 4)), measured on the worktree before the edit and at e381bcd9e1 after it:

File Tokens before Tokens after Bytes Lines
skills/objectstack-ui/rules/dashboards.md 6243 tokens (ceiling 6252; headroom 9) 6243 tokens (ceiling 6252; headroom 9) 24970 → 24969 468 → 468
skills/objectstack-query/rules/aggregation.md 1845 tokens (ceiling 2357; headroom 512) 1860 tokens (ceiling 2357; headroom 497) 7378 → 7437 241 → 241

Per sentence: the 'week' row 52 → 34 bytes; the engine sentence 185 → 202 bytes over the same three lines; the aggregation sentence 244 → 303 bytes over the same four lines. Net for dashboards.md: 0 tokens, 0 lines, −1 byte. No ceiling moved.

Gates

All runs at e381bcd9e1 (the branch's only commit); each runner log records the sha it started at.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 23 commands from the change set (2 paths vs merge base 55e6f14f8). All 23 exit 0. The 8 node scripts/check-*.mjs runs plus check:doc-formula-expressions, check:agent-test-spelling and check:corpus-claim-drift ran under scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 120s). The remaining 12 pnpm check:* scanners ran unlocked — a declared narrowing: the lock's own --status text places check:* gate scripts outside its coverage, and two consecutive lock calls answered queue-timeout (exit 99, 360s each) behind a holder at 13+ minutes.
  • check:doc-formula-expressions first answered exit 3 PREREQUISITE NOT MET (@objectstack/formula / @objectstack/lint not built — nothing measured). The production closure (spec, types, core, client, client-react, formula, sdui-parser, lint) was built under the lock (VERDICT command-exit 0, held 133s; git status clean afterwards), and the re-run exits 0: "22 record-scoped formula example(s) across 460 files / 1381 TS blocks judged clean by @objectstack/formula".
  • pnpm --filter @objectstack/spec run check:skill-docs exits 0: "✅ Skill docs in sync".
  • pnpm --filter @objectstack/spec run check:skill-examples exits 0 (run unlocked after a third queue-timeout, same declared narrowing): "✅ 260 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them". The diff sits outside every fence (dashboards.md fences close at 316 and reopen at 354; aggregation.md's close at 72 and reopen at 95), so no example changed.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran over the 23 commands with their exit codes: "23 derived famil(ies) accounted for — 23 run, 0 NOT-MEASURED (a DERIVED zero — all 23 recorded an exit code and none of them is 3)". The tool warns the tree is 3 commits behind origin/main (417443eb27, fetched after the runs); re-deriving against that merge-base yields the same 23 commands, and the three incoming commits (fix(cli): a narrowed os migrate --apply records no deployment flag, and an unknown --object is refused #21662, fix(objectql,spec)!: a hook's handler name resolves inside the hook's own package only (#21604) #21653, fix(objectql): a seed row keeps its authored created_at on insert, as the replay already does #21661) touch no skills/** path, so the branch was not merged forward for a two-file documentation change.
  • pnpm lint narrowing, with the three pieces of evidence: ① eslint.config.mjs files: globs cover only {ts,tsx,mts,cts,js,jsx,mjs,cjs} (lines 971–1238), so neither .md file is in the population; ② pnpm exec eslint --no-inline-config --format json over the two files answers 2 files, 0 errors, 1 warning each — "File ignored because no matching configuration was supplied."; ③ the config enables no parserOptions.project or typed rules (line 328), so this diff moves no untouched file's verdict.
  • No package is touched, so no build closure (①) and no package test / typecheck (②) is owed; check:nul-bytes is among the 23.

维护者速读(草稿)

改了什么 — 两个已发布技能里描述日期分桶引擎的三句话。dashboards 规则的「Engine support」句原说 Postgres 用 date_trunc、MongoDB 用 $dateTrunc、由 analytics service 发出;改为按五个臂点名驱动真实发出的表达式(Postgres to_char、MySQL date_format、SQLite strftime、MongoDB $dateToString、内存 bucketDateKey),并写明桶键是标签(如 2026-01)不是时刻。同一张表的 'week' 行由「ISO date of the bucket (YYYY-MM-DD)」改为 ISO 周标签 YYYY-Www。aggregation 规则的下推句把 DATE_TRUNC 换成同一表达式族。不改任何代码,不改产品行为。

为什么改 — 技能是 AI 作者读的权威面。写错引擎会让作者按 date_trunc 语义(时间戳形的桶键)去比较或解析桶值,而运行时五个臂实际都返回字符串标签;'week' 行与运行时每个臂返回的 2026-W29 不符。每个臂都在 origin/main(55e6f14f8d)上逐条读过代码,见上文 Reading 1。

风险与代价(含回滚) — 纯文档改动,8 行替换 8 行。token 棘轮:dashboards.md 净 0(6243/6252 不变,行数不变),aggregation.md +15(1860/2357)。23 条派生门禁加 check:skill-docs、check:skill-examples 全绿。回滚 = revert 本 PR 的单个 commit。

席位意见 — (留空)

你要做的 — 对这个 Tier H skills/** PR 给一次 APPROVED review;之后由 domain:skills#1 席位落地。

Acceptance notes

Noted, not filed (code comments, no behaviour, no carrier):

  • packages/services/service-analytics/src/dimension-labels.ts:302 — the formatDateBucket TSDoc example list still reads week → "2026-04-13" (ISO date of the bucket), while the body just below (:327–:333) returns a YYYY-Www key as written and relabels only a raw non-key value as its own day key. Comment drift only; the behaviour is pinned by the tests cited above.
  • packages/objectql/src/in-memory-aggregation.ts:59 — the header comment names date_trunc(...) as the SQL path's NULL-propagating expression; the SQL path emits to_char / date_format / strftime, whose NULL propagation is the same point. Comment drift only.

Generated by Claude Code

The dashboards rule said Postgres buckets with date_trunc and MongoDB with
$dateTrunc; the drivers emit a label (to_char / date_format / strftime /
$dateToString, in memory bucketDateKey), never an instant. The 'week' row
now reads the ISO week label YYYY-Www every arm answers, and the
aggregation rule's push-down sentence names the same expression family
instead of DATE_TRUNC.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CB6W87z22K2yjUCDyVrJRk
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation labels Oct 4, 2026
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 4, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e381bcd9e137fc6dac9987b72b8527ac02f0da76
Local-runs: probe — node scripts/check-skills-token-ratchet.mjs run once in a detached read-only worktree at this head, so the token readings in ③ are this seat's own rather than the dev's; nothing built, nothing else run.

Read-only shape otherwise: the diff against the merge base 55e6f14, card #21588 with every comment, the driver arms on origin/main, and this head's check-runs. Reviewed by the dispatch seat in seat (served tier equals the constant's value, read from get_session). Review face: skills/**, governed rule text (Tier H). Readings taken at 2026-10-04T03:21Z.

① Derived judgments

  • Accept set: unchanged. Prose in two published skill files (skills/objectstack-ui/rules/dashboards.md and skills/objectstack-query/rules/aggregation.md, +8/−8 together); no schema, export, lint rule, error code or runtime behaviour moves. Clause-②: no holds.
  • The ruling's lines, each read by this seat against origin/main: the engine sentence names what each arm emits — PostgreSQL to_char (packages/drivers/driver-sql/src/sql-driver.ts buildDateBucketExpr, :6164–:6168, week IYYY"-W"IW), MySQL date_format (:6175 on, week %x-W%v), SQLite strftime with its own week case (:6183–:6208, the ISO-week arm PR fix(driver-sql,service-analytics): bucket the ISO week natively on SQLite, and the SQL echo refuses a bucket SQLite cannot run #21629 landed), MongoDB $dateToString with %G-W%V (packages/drivers/driver-mongodb/src/mongodb-aggregation.ts :246–:275, under the docblock at :194 "Labels, not instants — and therefore no $dateTrunc"), in-memory bucketDateKey (packages/core/src/utils/datetime.ts:313), chosen through supports.queryDateGranularity (packages/objectql/src/engine.ts:17617); it says the driver emits the bucket as a label (2026-01), not an instant — yes; the "analytics service" attribution is gone — yes. The 'week' row's YYYY-Www is the label every arm answers and the one the analytics label path returns as written (formatDateBucket, packages/services/service-analytics/src/dimension-labels.ts:321; pinned by dataset-granularity-postprocess.test.ts:66, week 2026-W29). aggregation.md names the same expression family in place of DATE_TRUNC and keeps the push-down / in-memory-fallback clause.
  • Scope as the claim folded it (5975736098): the three sentences of one family and nothing else — the diff holds exactly those three (hunks at dashboards.md :321 and :326–:328, aggregation.md :82–:85); 468 → 468 and 241 → 241 lines.

② Semver level

  • No released package publishes from this diff (skills/** is outside every published package's files[]); no changeset owed; skip-changeset is the correct declaration. No ADR-0087 disposition applies.

③ Boundary flags

  • Dev flags: open_questions empty. Two out_of_scope_findings, both code comments with no behaviour (dimension-labels.ts:302 docblock example for week; in-memory-aggregation.ts:59 header naming date_trunc), class none, reach none — disposed on the ACCEPT as Acceptance notes.
  • Ratchet, read off the head tree by this seat against origin/main: dashboards.md 6243 → 6243 tokens (ceiling 6252, headroom 9 unchanged), 24970 → 24969 bytes; aggregation.md 1845 → 1860 tokens (ceiling 2357, headroom 512 → 497). No ceiling moved.
  • Deviation read: 12 of the 23 derived scanners ran outside the verify lock after two queue-timeouts behind a sibling's long gate, with the lock's own status text placing check:* scanners outside its coverage — a declared narrowing of the lock discipline, not of the measurements (every exit code recorded, --ran reconciled 23/23); mcp_calls 0; api_writes 4 over 3 dispatches as listed; report comment 5976088771 present and parses.
  • Check-runs on this head at this write: 18 success, 11 skipped, 4 in progress — the enqueue gate reads them at landing, not this record.

Implemented-by: claude/issue-21588-dashboards-date-bucket-engine-sentences
Reviewed-by: session_01CB6W87z22K2yjUCDyVrJRk

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)— PR #21677(#21588)· skills seat 1 · 2026-10-04T03:22Z

改了什么: 两个对外发布技能里描述「日期分桶引擎」的三句话。① skills/objectstack-ui/rules/dashboards.md 的 Engine support 句:改为「driver 把桶作为标签(如 2026-01)而非时刻发出」,并逐引擎点名真实表达式 —— Postgres to_char、MySQL date_format、SQLite strftime、MongoDB $dateToString、内存 bucketDateKey;② 同文件键表的 'week' 行由「桶的 ISO 日期 YYYY-MM-DD」改为「ISO 周 YYYY-Www」;③ skills/objectstack-query/rules/aggregation.md 的下推句把 DATE_TRUNC 换成同一表达式族,下推/内存回退语义保留。两文件各 +4/−4,行数 468 → 468、241 → 241;token 棘轮 dashboards 6243 / 6252 未动(headroom 9 不变),aggregation 1845 → 1860(上限 2357)。

为什么改: 技能是 AI 作者的直接依据。原文说 Postgres 用 date_trunc、Mongo 用 $dateTrunc、周桶键是日期,而代码里五条臂都发出标签(Postgres IYYY"-W"IW、MySQL %x-W%v、SQLite 自 PR #21629 起同样答 ISO 周、Mongo $dateToString %G-W%V、内存 bucketDateKey),分析层也原样返回这个键(测试钉在 2026-W29)。按 date_trunc 语义推理的作者会把桶键当时刻去比较或解析,写出错的仪表盘过滤。这三句是同一族(engine 席在卡上补了后两句的实测),席位并成一个 PR、一次人工审阅。

风险与代价(含回滚): 纯技能文本,不碰 packages/**,无 changeset(skip-changeset);席内契约复核 PASS(本 PR 上一条评论),dev 逐臂贴了代码行号读数,席位在 origin/main 上逐臂复核一致;CI 此刻 18 绿、11 预期 skip、4 在跑。回滚 = revert 单个 commit e381bcd。已知残余(不在本 PR):service-analytics 与 objectql 里两处只是注释的旧说法(docblock 示例、文件头注释),无行为影响,记在 PR 的 Acceptance notes,未立卡。

席位意见: 建议批准。三句各自对照代码核过;dashboards.md 在 token 上限 9 的余量内以词换词完成,未动上限;键表 week 行与所有臂、分析层标签路径一致。

你要做的(一个动作): 在 PR #21677 上给一次 APPROVED review;批准后由席位清标、ready、挂 auto-merge 入队。

@os-zhuang
os-zhuang marked this pull request as ready for review October 4, 2026 03:25
@os-zhuang
os-zhuang enabled auto-merge October 4, 2026 03:25
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit eed2dee Oct 4, 2026
44 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-21588-dashboards-date-bucket-engine-sentences branch October 4, 2026 04:04
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…er 0 write wall, as the update does (objectstack-ai#21666) (objectstack-ai#21680)

Fixes objectstack-ai#21666
Clause-②: no (narrowing)

## What changes

On a walled posture, the insert stamp in `@objectstack/organizations`
(Middleware A) used to **overwrite** a supplied `organization_id` with
the caller's active organization in every user context. The overwrite is
`packages/plugins/organizations/src/organizations-plugin.ts:334` at
`72f3c74d60`: `data.organization_id = opCtx.context.tenantId;` inside
`if (isUserContext)`.

The stamp now **fills only an absent or empty value**, for every
non-system context (ADR-0105 D5). A supplied value is left as sent and
meets the Layer 0 write wall in `plugin-security` (step 3.7, ADR-0095
D1). That is the wall the PATCH already meets, so the create now gets
the PATCH's answer. This follows triage ruling 5975961814: "the
governed, loud side wins" and "⛔ No silent replacement on any posture."

No `plugin-security` code changes. The wall was already symmetric (see
Zone 2.2 below). No `packages/spec`, `packages/objectql` or
`packages/metadata-protocol` edit.

## Measured on a real walled boot (before → after)

Harness: `@objectstack/verify` `bootStack(app, { multiTenant: true,
hostRoot })`, run from a scratch host app that declares the real
`@objectstack/organizations`. The stack is the real `SecurityPlugin`,
the real REST routes and `sqlite-wasm`. Requests go over HTTP to
`/api/v1/data/...`. Orgs: **A** is the caller's active organization,
**B** is another tenant ("Tenant North D2"), and **C** is a sister
organization the caller also holds. Objects: `sys_user_permission_set`
and `sys_business_unit` (platform objects that declare their own
`organization_id`), `qa_ledger` (a public app object with the injected
`organization_id`) and `qa_vault` (a private app object, where a
platform admin is posture-exempt).

### `isolated`

| caller | object | create naming B | PATCH to B | `createMany` [B] |
|---|---|---|---|---|
| platform admin | `sys_user_permission_set` | 201, stored A → **403
PERMISSION_DENIED** | 403 PERMISSION_DENIED (both) | 403 (both) |
| platform admin | `sys_business_unit` | 201, stored A → **403
PERMISSION_DENIED** | 403 (both) | 403 (both) |
| platform admin | `qa_ledger` | 201, stored A → **403
PERMISSION_DENIED** | 403 (both) | 403 (both) |
| platform admin | `qa_vault` (exempt) | 201, stored A + `droppedFields`
readonly (unchanged) | 200, stored A + `droppedFields` (both) | 201,
stored A + `droppedFields` (both) |
| member | `qa_ledger` | 201, stored A → **403 PERMISSION_DENIED** | 403
(both) | 403 (both) |
| member | `qa_vault` | 201, stored A → **403 PERMISSION_DENIED** | 403
(both) | 403 (both) |

A create naming no organization, or naming A, answers 201 and is stored
in A, before and after, in every row above.

### `group`

| caller | object | create naming B | create naming C | PATCH to C |
|---|---|---|---|---|
| platform admin | `sys_user_permission_set` | 201 A → **403** | 201 A →
**201 C** | 200 C (both) |
| platform admin | `sys_business_unit` | 201 A → **403** | 201 A → **201
C** | 200 C (both) |
| platform admin | `qa_ledger` | 201 A → **403** | 201 A + dropped
(unchanged) | 200 A + dropped (both) |
| platform admin | `qa_vault` | 201 A + dropped (unchanged, exempt) |
201 A + dropped (unchanged) | 200 A + dropped (both) |
| member | `qa_ledger` | 201 A → **403** | 201 A + dropped (unchanged) |
200 A + dropped (both) |
| member | `qa_vault` | 201 A → **403** | 201 A + dropped (unchanged) |
200 A + dropped (both) |

PATCH to B and `createMany` [B] were 403 in every row, before and after.

### `single` (the control)

`objectstack serve` mounts the runtime only under a walled posture, so
the production `single` shape has no Middleware A. Every cell there is
byte-identical before and after. For example, `sys_user_permission_set`
create B answers 201 stored B, PATCH B answers 200 stored B, and
`createMany` [B] answers 201 stored B. As an extra non-production cell,
I also mounted the runtime under `single` by hand. The create naming B
moved from 201 stored A to 201 stored B, which now matches that boot's
PATCH and `createMany`.

### Other single-row doors (same middleware)

- **Import** (`POST /data/:object/import`, `isolated`; rows [B, none]).
The batch is refused by the wall and degrades to per-row `createData`.
Before, both rows reported `ok` and were stored in A. After, the B row
reports `PERMISSION_DENIED` and the none row reports `ok` in A. Measured
as admin on `sys_business_unit`, as admin on `qa_ledger`, and as member
on `qa_ledger`.
- **Clone** (`POST /data/:object/:id/clone`, `group`, source row in C).
For `sys_business_unit` it was 201 A and is now 201 C. For
`sys_user_permission_set` it was 201 A and is now **409**: the copy
would duplicate its source's `(user, set, organization)` key in C. For
`qa_ledger`, the clone strips the injected column, so it is 201 A both
before and after.
- The `create` operation of `POST /batch` writes row by row through the
same path. I read this in code; I did not measure it.

### Zone 2.2: insert vs update on the wall

There is no asymmetry. Both verbs reach `computeWriteTenantCheckFilter`
→ `computeLayeredRlsFilter`, and they share the platform-admin exemption
(only on posture-permitting objects: `private`, platform-global,
better-auth-managed). They throw the same `PermissionDeniedError`,
`code: PERMISSION_DENIED` with status 403. The message names the verb:
"the insert would place …" and "the update would place …". So
`security-plugin.ts` is not edited. Its step 3.7 comment already said
the stamp "only fills a MISSING value, never overwrites a supplied one",
and that sentence is now true.

## Census: who relied on the overwrite (triage's stop condition)

| writer | context | sets `organization_id` itself? | reliant? |
|---|---|---|---|
| per-org seed replay (`seed-loader` `SEED_OPTIONS`) | system | yes |
no: skips Middleware A |
| default-org bootstrap (`ensureDefaultOrganization`,
`claimOrgSeedOwnership`) | system | yes | no |
| orphan claim (`claimOrphanOrgRows`) | system, update | yes | no:
insert-only middleware |
| sharing, approvals, audit, auto-org-admin grant, invitation placement,
email, settings audit, better-auth adapter | system | yes | no |
| storage `metadata-store` (`sys_file`, `sys_upload_session`) | caller |
`= context.tenantId` | no: always equal |
| messaging, outboxes, `sys-metadata-repository`, `database-loader` | no
`tenantId` on the context | yes | no: the middleware no-ops |
| REST import runner (`core/import-runner`) | caller | from the user's
file | user input, not a platform writer; see Import above |
| REST clone (`metadata-protocol` `cloneData`) | caller | copied from
the source when the object declares the column | see Acceptance notes |
| flow `create_record` (runAs user) | caller | flow-authored fields |
user-authored input, same as REST |

No non-system writer sets an `organization_id` and relies on the
overwrite to correct it, so this is not a stop. `seed-loader.ts`
(claimed by objectstack-ai#21665) was read only.

## Pins (real runtime: this package's Middleware A + real
`SecurityPlugin` + `ObjectQL` + `SqliteWasmDriver`)

New file
`packages/plugins/organizations/src/create-explicit-organization-wall.test.ts`,
14 cases:

- **Another tenant's organization.** A create naming it is refused with
the PATCH's code and status. Covered for a member and for a platform
admin, on a declared-column object and on an injected-column object.
- **Array insert.** An array insert naming it gets the single-row
answer.
- **No organization.** A create naming none is stamped with the active
organization **before the hooks run** (asserted at the `beforeInsert`
payload) and stored there.
- **Own active organization.** A create naming it is admitted.
- **objectstack-ai#2937.** A member's forged `organization_id` is refused, and no row
lands in either tenant.
- **System context.** An explicit cross-organization value is kept (the
seed-replay path).
- **`group`.** A sister organization is admitted on create, as on the
PATCH. An organization outside the membership set is refused with the
PATCH's code.

`organizations-plugin.test.ts`: the old "OVERWRITES a forged
organization_id" unit is now "leaves a supplied organization_id
untouched". I added an empty-string fill unit.

## Ablations (predicted direction stated before each run; both through
`scripts/ablation-replace.mjs`, restore proven by blob == HEAD and `git
diff HEAD` empty)

1. **Restore the overwrite.** I predicted red on exactly the 9 wall-file
cells where a create names an organization and expects a refusal or a
non-active placement (6 refusals, 2 array/single parity cells, the group
sister cell), plus the one unit "leaves … untouched". Observed: **10
failed, 113 passed (123)**, exactly those cells. The stamped,
own-organization and system cells stayed green.
2. **Drop the fill for an absent value.** I predicted red on exactly the
2 "stamped before the hooks run" cells and the 2 fill units (absent,
empty). Observed: **4 failed, 119 passed (123)**, exactly those. The
failure reads `the beforeInsert chain sees the stamp: expected [
undefined ] to deeply equal [ 'org_alpha' ]`. The stored-row half alone
could not catch this ablation. The SQL driver fills the same value from
`DriverOptions.tenantId`, measured: `createMany` [none], which
Middleware A never touches, lands in A. That is why the pin asserts the
payload the hooks see.

## Verification (all at `904a8e25c4`)

- ① `pnpm --workspace-concurrency=2 --filter
'@objectstack/organizations^...' build`: exit 0, 29 projects.
- ② `pnpm --filter @objectstack/organizations test`: 9 files, **123
passed**. `typecheck`: exit 0 (tsc and the test layer, 0 errors). `pnpm
--filter @objectstack/plugin-security test`: 164 files, **3527 passed**,
45 skipped.
- ③ `dispatch-gates --commands` (no paths) derived **105** families.
**104 exit 0.** **NOT MEASURED: `check:dual-build-cjs-loads`**, which
exits 3 (PREREQUISITE NOT MET: 32 packages have no `dist/`, and it needs
a full build; CI owns it). `check:skill-examples` first exited 3 for
lack of a client build. I built `@objectstack/client` and `client-react`
and it then exited 0. `--ran` reconciliation: 105 derived, 104 run, 1
NOT MEASURED (derived from the recorded exit 3), 0 unrun.
- ESLint, narrowed to the 4 touched lintable files (`--no-inline-config
--format json`): 4 files, 0 errors, 0 warnings. All 4 are inside the
population of the `files` globs in `eslint.config.mjs` (`--print-config`
resolves for each). The other touched files (`.md`, `.json`, `.yaml`)
match no lint glob. The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move a verdict on an
untouched file.
- Dogfood walled-posture files: `rls-multitenant` skips by design
(`@objectstack/dogfood` does not declare the runtime), and
`enterprise-organizations.test.ts` passes 13. **NOT MEASURED:
`attachments-permission-matrix`**: `@objectstack/service-storage` is
unbuilt, and its multi-org block is `skipIf` there anyway. The walled
HTTP measurement above is this card's dogfood.
- The derivation flagged the tree as behind `origin/main` by 3 commits
(objectstack-ai#21664, objectstack-ai#21674, objectstack-ai#21677). None touches `organizations`,
`plugin-security`, `objectql` or the files here. The only gate file
among them is `scripts/cross-package-test-inputs.mjs`.

## Docs and skills

- `content/docs/permissions/system-context.mdx` row 61 said "a forged
`organization_id` is overwritten on the non-elevated path". This PR made
that false, and the row now says the wall refuses it, as it refuses the
update.
- `content/docs/deployment/tenancy-modes.mdx` ("Filling in an absent
`organization_id` … validating a supplied one") was false before and is
true now, so it is untouched.
- `skills/**`: no sentence about the insert stamp or about
`organization_id` on create, so nothing is false there.

## Package and lockfile

- `@objectstack/organizations` gains three devDependencies: `objectql`,
`plugin-security`, `driver-sqlite-wasm`. Each is aliased to source in
`vitest.config.ts`, as `check:test-source-alias` requires.
- `pnpm-lock.yaml` carries only the organizations importer hunk. `pnpm
install` also flipped an unrelated `esbuild` peer suffix in two other
importers, and that churn was dropped. `pnpm install --frozen-lockfile`
passes.

## Changeset

`.changeset/21666-create-explicit-organization-meets-wall.md`:
`@objectstack/organizations` `minor`, `fix(organizations)!`, a
`**BREAKING.**` marker, and `Clause-②: no (narrowing)`. The accept set
narrows: a create, or an import row, naming another tenant's
organization answered 201 and is now refused. The ADR-0087 disposition
is `not-required (no-migration-prescription)`, and
`check:adr-0087-registration` is green on it. The one-line fix: omit
`organization_id` on create or name your active organization, and a
platform operator moves a row with a system-context write.
`@objectstack/plugin-security` is unchanged, so it has no entry.

## Acceptance notes

- **Out-of-scope finding (class a), not fixed here.** On a walled
posture, a create that sends **no** `organization_id` to an app object
answers 201 with `droppedFields: [{ fields: ['organization_id'], reason:
'readonly' }]`, naming a field the caller never sent. Middleware A's
fill lands in the payload before `ObjectQL.insert` snapshots "what the
caller sent", so the static-readonly strip reports the platform's own
stamp as a caller write. Controls: `single` without the runtime reports
nothing, and `createMany` [none] on `isolated` reports nothing. The seam
is in `packages/objectql`, outside this card's surface, and this PR
leaves it unchanged. It goes to the seat to file.
- **Clone under `group`.** The clone door copies an `organization_id`
that the object declares itself. A clone of a sister-organization row
therefore now lands beside its source (or answers 409 on a unique key)
instead of being re-homed into the active organization. The wall admits
it, and so does a PATCH. Whether the clone door should strip a declared
`organization_id` is a `metadata-protocol` question for the seat. This
card neither answers nor edits it.
- **Observation.** During the scratch import, `driver-sql` logged
`DATABASE_ERROR … no such table: _objectstack_sequences`. The import
still completed, the log is unrelated to this diff, and I have not filed
it.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

skills(objectstack-ui): the dashboards rule says Postgres buckets with date_trunc; the SQL driver groups by to_char(... AT TIME ZONE UTC) on Postgres

3 participants