Repository navigation
docs(skills): date-bucket engine sentences name what each arm emits - #21677
Conversation
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
Contract reviewServed-tier: Read-only shape otherwise: the diff against the merge base 55e6f14, card #21588 with every comment, the driver arms on ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
维护者速读(终稿)— PR #21677(#21588)· skills seat 1 · 2026-10-04T03:22Z改了什么: 两个对外发布技能里描述「日期分桶引擎」的三句话。① 为什么改: 技能是 AI 作者的直接依据。原文说 Postgres 用 风险与代价(含回滚): 纯技能文本,不碰 席位意见: 建议批准。三句各自对照代码核过; 你要做的(一个动作): 在 PR #21677 上给一次 APPROVED review;批准后由席位清标、ready、挂 auto-merge 入队。 |
…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>
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/mainat55e6f14f8d, and nothing else in either file.skills/objectstack-ui/rules/dashboards.md'week'row: "ISO date of the bucket (YYYY-MM-DD)"YYYY-Www"skills/objectstack-ui/rules/dashboards.mddate_trunc, MySQLdate_format, SQLitestrftime, MongoDB$dateTrunc, in-memory fallback. All emitted by the analytics service, not the client."2026-01), not an instant: Postgresto_char, MySQLdate_format, SQLitestrftime, MongoDB$dateToString, in-memorybucketDateKey."skills/objectstack-query/rules/aggregation.mdDATE_TRUNCetc.)"to_char/date_format/strftime/$dateToString, neverdate_trunc)" — the push-down / in-memory-fallback clause and "including the column keys" are kept as they wereThe ruling on the card (triage comment 5969875145) fixed the engine sentence and said no other sentence in
dashboards.mdmoves; 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-Wwwon all five.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 aField.date; formatsYYYY,YYYY-MM,YYYY-MM-DD,YYYY"-Q"Q,IYYY"-W"IW2026-01,2026-Q1,2026-W23sql-driver.ts:6172–6180date_format(convert_tz(col, @@session.time_zone, '+00:00'), FORMAT)(barecolfor aField.date);%Y,%Y-%m,%Y-%m-%d,%x-W%v; quarter isconcat(date_format(…, '%Y'), '-Q', quarter(…))2026-01,2026-W23sql-driver.ts:6183–6208strftime(FORMAT, ARG)with%Y,%Y-%m,%Y-%m-%d; quarter from%Yand(%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, onorigin/main)2026-01,2026-W23packages/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$concatof%Y,-Qand a$switchover%m. The docblock at:194is headed "Labels, not instants — and therefore no$dateTrunc"2026-01,2026-W23packages/core/src/utils/datetime.ts:313bucketDateKey(week viaisoWeekLabelFromCalendarDay,:389); the engine's fallbackpackages/objectql/src/in-memory-aggregation.ts:375delegates to it, andpackages/objectql/src/engine.ts:17615picks push-down vs fallback fromsupports.queryDateGranularityYEAR,YEAR-MM,YEAR-MM-DD,YEAR-Qn,ISOYEAR-Wwwbuilt from the calendar parts in the reference zone — the writer the drivers' expressions are held equal to (checkDateBucketParity)2026-01,2026-W23The rendered dashboard label is the key:
packages/services/service-analytics/src/dimension-labels.ts:321–333formatDateBucketreturns a key the writer wrote at that granularity as written (bucketKeyToCalendarRange(value, granularity) !== null), andsrc/__tests__/dataset-granularity-postprocess.test.ts:66pinsweek: '2026-W29'throughqueryDataset. 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 ate381bcd9e1after it:skills/objectstack-ui/rules/dashboards.md6243 tokens (ceiling 6252; headroom 9)6243 tokens (ceiling 6252; headroom 9)skills/objectstack-query/rules/aggregation.md1845 tokens (ceiling 2357; headroom 512)1860 tokens (ceiling 2357; headroom 497)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 fordashboards.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/objectstackderives 23 commands from the change set (2 paths vs merge base55e6f14f8). All 23 exit 0. The 8node scripts/check-*.mjsruns pluscheck:doc-formula-expressions,check:agent-test-spellingandcheck:corpus-claim-driftran underscripts/pm/os-verify-lock.sh(VERDICT command-exit 0, held 120s). The remaining 12pnpm check:*scanners ran unlocked — a declared narrowing: the lock's own--statustext placescheck:*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-expressionsfirst answered exit 3 PREREQUISITE NOT MET (@objectstack/formula/@objectstack/lintnot 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 statusclean 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-docsexits 0: "✅ Skill docs in sync".pnpm --filter @objectstack/spec run check:skill-examplesexits 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 --ranover 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 behindorigin/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 noskills/**path, so the branch was not merged forward for a two-file documentation change.pnpm lintnarrowing, with the three pieces of evidence: ①eslint.config.mjsfiles:globs cover only{ts,tsx,mts,cts,js,jsx,mjs,cjs}(lines 971–1238), so neither.mdfile is in the population; ②pnpm exec eslint --no-inline-config --format jsonover the two files answers 2 files, 0 errors, 1 warning each — "File ignored because no matching configuration was supplied."; ③ the config enables noparserOptions.projector typed rules (line 328), so this diff moves no untouched file's verdict.check:nul-bytesis among the 23.维护者速读(草稿)
改了什么 — 两个已发布技能里描述日期分桶引擎的三句话。dashboards 规则的「Engine support」句原说 Postgres 用
date_trunc、MongoDB 用$dateTrunc、由 analytics service 发出;改为按五个臂点名驱动真实发出的表达式(Postgresto_char、MySQLdate_format、SQLitestrftime、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— theformatDateBucketTSDoc example list still readsweek → "2026-04-13" (ISO date of the bucket), while the body just below (:327–:333) returns aYYYY-Wwwkey 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 namesdate_trunc(...)as the SQL path's NULL-propagating expression; the SQL path emitsto_char/date_format/strftime, whose NULL propagation is the same point. Comment drift only.Generated by Claude Code