Skip to content

fix(plugin-auth): the admin identity rows on the compliance ledger record the admin's decisions, never a value of a user field (#21174) - #21195

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21174-admin-audit-metadata
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21174-admin-audit-metadata

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21174
Clause-②: no

The compliance-ledger rows that plugin-auth's admin identity endpoints write themselves now record the admin's decisions and a reference to the user, never a value of a field of that user. The values those calls write into the user's fields stay where the CRUD mirror already records them: on its create and update rows for the same writes, in the before/after snapshot columns that PR #21171 narrows per reader. This follows triage's direction on the card (5932908541): the row records the decision or a reference to the record, never a parent field value in free metadata, and there is no read-time mapping of decision names back to fields.

Measured first, on a real boot

Re-measured privately on main at b9087d77 (PR #21171 in). The stack was bootStack with the real SecurityPlugin, ObjectQL, SQL driver, REST and auth layers (the admin plugin on), plus AuditPlugin. The seeded platform admin called the create-user and set-user-password doors. Readers read the ledger through the generic list door. Readings stay private to the dispatch; this table gives classes only.

Reader class Mirror rows about the user (PR #21171) Explicit admin create row, before After
A user field it is not granted (one reader per written field, four fields) that field absent that field's value in metadata absent
A user field served masked (layered by an object extension) absent value in metadata absent
A capability-gated user field, no mask (layered the same way) absent value in metadata absent
Platform read-only wildcard plus a withholding set every class absent every class's value in metadata absent
Unrestricted reader (control) every value every value in metadata every value, through the mirror rows only

The set-user-password row carried the value of one written field the same way. The data plane answered every reader 404 for the created user, the control included (see the acceptance notes).

What changed

  • packages/plugins/plugin-auth/src/admin-user-endpoints.ts:
    • runAdminCreateUser no longer copies the four values it writes into the user's fields into the row's metadata. runAdminSetUserPassword no longer copies the one it writes.
    • writeAdminAudit takes a closed AdminAuditDecisions type: the operation, whether the password was generated, whether the account's address is a generated placeholder, whether the membership was bound, and the bound organization as a reference. Each member is either a decision no field of the user stores, or a reference to another record. The row's reference to the user is its own object_name and record_id.
    • The header that justifies the explicit row, and the durability warning that lists its decisions, now say the same.
  • .changeset/21174-admin-audit-metadata.md: patch for @objectstack/plugin-auth, with the migration line for a reader that took a value from these rows.

The type refuses a field value written as a literal key at compile time. A conditional spread passes TypeScript's excess-property check, so the type does not stop that spelling; the unit pins do. The ablations below measured both.

Census: every non-mirror ledger writer in domain:services

Every sys_audit_log insert outside the CRUD mirror, found by grepping the lane's packages (plugin-auth, plugin-security, plugin-sharing, plugin-approvals, plugin-audit, plugin-webhooks, plugin-email, embedder-openai, knowledge-*, services/*, connectors/*, triggers/*) for the ledger's name and for its constants. plugin-sharing, plugin-approvals, the connectors, the triggers and every other service name the ledger in comments only.

Writer Row What metadata carries Verdict
plugin-auth admin-user-endpoints.ts#writeAdminAudit, create-user create on the user the decisions, plus four values written into the user's fields fixed here
same, set-user-password update on the user the decisions, plus one value written into the user fixed here
plugin-auth admin-import-users.ts, run-level row import, record_id null the run's mode, match key name and password policy, and counts already clean: no record value
plugin-auth auth-session-audit.ts events, written by plugin-audit auth-event-audit.ts login / logout on the session the endpoint path; on an impersonation session, the impersonating admin's id clean of a field value: the id is a reference and is the row's own actor column. The client fingerprint rides the ledger's own columns, not metadata
plugin-audit read-audit.ts record-view rows nothing already clean
service-settings config-change-audit.ts config_change, record_id null the setting's composite identity (its reference), a flag derived from the setting's declaration, the request id already clean: the value rides the snapshot column as a digest only
plugin-security platform-admin-standing-audit.ts platform_admin_standing_change the event and two counts already clean; plugin-security is outside this card's file surface and untouched

No in-lane sibling needed an edit, so no edit outside plugin-auth was made beyond the tests and the changeset.

Downstream readers of the dropped values

  • packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts asserted one dropped value on the explicit create row. That case now asserts the explicit row does not carry it, and that the mirror's update row for the same write does.
  • The console's audit-log browser (objectui apps/console/src/pages/system/AuditLogPage.tsx, read at the pinned .objectui-sha) renders metadata as pretty-printed JSON. It reads no key by name.
  • ADR-0093's two membership decisions on this row are kept.
  • No other reader in this repository names any dropped key.

Pins

  • packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts, 4 new cases. For each row it asserts:

    • the row's reference to the user;
    • the exact decision key set;
    • that no key names a field this call wrote, and no value equals a string the call wrote.

    The cases cover create-user with every optional input, phone-only create, a create that binds a membership, and set-user-password. Each case first checks that the call really wrote those fields.

  • packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts, 8 cases, on a real boot.

    • The platform declares no mask and no capability gate on the user fields these endpoints write. So an object extension layers one written field masked and one capability-gated. A permission set withholds a third.
    • There is one reader per class, the read-only-wildcard reader, and the control. The seeded admin drives both doors.
    • beforeAll arms it with assertArmed. The explicit rows exist at rest, and the mirror rows at rest carry every class. The mirror rows served to each reader withhold exactly its class, and the control is served every class.
    • Per class, through the list, by-id and projected doors: both explicit rows are served to the reader, and no row served to it carries a value of its class. Separate cases show the two other classes still reach it through the mirror.
    • The control is served every class through the mirror, and the explicit rows carry only the closed decision set.
    • No test title states a value.

Ablations: every negative pin, put back and shown red

All legs went through scripts/ablation-replace.mjs. Each anchor hit once and landed by count and blob. Each restore was proven by blob equal to HEAD and an empty git diff HEAD.

The first unit attempt was a no-op. Its replacement text contained its own anchor, so the tool refused before running anything. Every leg was re-spelled and run again.

Unit legs, src-resolved, over the 41-case file:

Put back Red
the created account's sign-in identifier, create row 3 of the 4 new cases
its phone identifier, create row 2
its legacy role scalar, create row 2
its force-password-change flag, create row 3
the same flag, password-set row 1
a written value smuggled inside a declared decision key 4; the value detector names the leak in the phone-only case
the identifier as a literal key, under tsc --noEmit TS2353 on the closed type

Every other case stayed green, and the restore run read 41/41.

Dogfood legs, resolved through dist. Each leg rebuilt plugin-auth, and scripts/ablation-dist-preflight.mjs proved the marker present in dist. Three legs used a literal key, and their declaration build failed on the closed type: the JS emitted, the preflight proved it, and the suite ran on it. The spread leg built clean.

Put back Red, of 11 (the new 8 plus the audit-trail file's 3)
the not-granted class's value, create row 3: that class, the wildcard, the control
the masked class's value, create row (a spread) 3: that class, the wildcard, the control
the gated class's flag, create row 4: that class, the wildcard, the control, and the audit-trail case
the gated class's flag, password-set row 3: that class, the wildcard, the control

In every leg the preservation cases and the other classes' cases stayed green. The restore leg rebuilt plugin-auth and proved all four markers absent with --absent on a clean whole tree. It then went 11/11 green.

The same 11 cases were also run against the pre-fix build. Red: the 5 negative cases and the audit-trail case. Green: the 3 preservation cases and the 2 untouched audit-trail cases.

Verification at head 0f314432

origin/main was merged once, with no overlap with this diff; then a reinstall and a rebuild of the dogfood closure.

  • pnpm --filter @objectstack/plugin-auth test: 116 files, 2484 tests passed.
  • pnpm --filter @objectstack/plugin-auth typecheck: exit 0. The test-typecheck debt is held unchanged: 10 files, 94 errors, 23 signatures.
  • Dogfood vitest --project isolated, 9 files and 59 tests passed:
    • the new pin and the audit-trail file;
    • the five other suites that drive the admin endpoints (admin-credential-lifecycle, admin-platform-admin-standing, admin-route-nonadmin-refusal, bearer-lane-password-change, membership-reconciler);
    • the ledger readers audit-log-field-values and auth-session-audit-trail.
  • Dogfood typecheck: exit 0.
  • Gates: dispatch-gates --repo objectstack-ai/objectstack --commands derived 67 families, with no paths. All 67 were run with exit codes captured before any pipe, and all exited 0. --ran reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) because eight unrelated packages had no dist. Those packages were built and it re-ran to exit 0.
  • Lint, a proven narrowing: eslint --no-inline-config --format json over the 4 changed TS files reported 4 files, none ignored, 0 errors and 0 warnings. eslint.config.mjs enables no type-aware linting, so no untouched file's verdict can move. The repo-wide lint is CI's.
  • Size: 594 changed lines (+581 / -13) across 5 files against merge base 2c1cef33. No governed surface.

Acceptance notes


Generated by Claude Code

claude added 4 commits October 1, 2026 14:38
…cord the admin's decisions, never a field value of the user

The explicit sys_audit_log rows written by the admin create-user and
set-user-password endpoints copied values those calls write into the
user's fields into free metadata, where the ledger's read-time snapshot
narrowing cannot reach them. The row now carries a closed decision set
plus its object_name/record_id reference; the values ride the mirror's
narrowed snapshot columns.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
… class on a real boot

A reader of each field class (masked, capability-gated, not granted) and
the read-only wildcard reader are served both explicit admin rows and no
value of their class through the list, by-id and projected doors; the
unmasking control still receives every class through the mirror's
snapshots, and the explicit rows carry only the closed decision set. The
audit-trail pin now reads the must-change-password stamp from the
mirror's update row. Adds the patch changeset.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…ecision type

The excess-property check refuses a literal key but not a conditional
spread; the unit pins are the guard for that spelling. Measured by the
ablation legs: a literal-key leg fails the declaration build, a spread
leg builds and is caught by the pins.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/automation/hooks.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/data-modeling/seed-data.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/deployment/seed-tenancy-repair.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/kernel/events.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/kernel/runtime-services/audit-service.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/permissions/authentication.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/permissions/system-context.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/protocol/kernel/config-resolution.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/releases/v16.mdx (via organizationId (symbol, a field of type AdminAuditDecisions))
  • content/docs/releases/v17/17-5.mdx (via membershipCreated (symbol, a field of type AdminAuditDecisions), organizationId (symbol, a field of type AdminAuditDecisions))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (4 routes)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0b12b9ea8f36a9d561b385d1d0484966315d6e89 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1b131e68d4d9b2efecc98b1ab42e60d5668c38ab — the merge of head 0f3144328128e834f73286906d24992f8221163b into base 0b12b9ea8f36a9d561b385d1d0484966315d6e89, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1b131e68d4d9b2efecc98b1ab42e60d5668c38ab && git checkout 1b131e68d4d9b2efecc98b1ab42e60d5668c38ab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0b12b9ea8f36a9d561b385d1d0484966315d6e89 0f3144328128e834f73286906d24992f8221163b && git checkout -B drift-repro 0b12b9ea8f36a9d561b385d1d0484966315d6e89 && git merge --no-ff 0f3144328128e834f73286906d24992f8221163b

node scripts/docs-audit/affected-docs.mjs --json 0b12b9ea8f36a9d561b385d1d0484966315d6e89

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0b12b9ea8f36a9d561b385d1d0484966315d6e89 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36889082281 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Temporal Conformance (live PG + MySQL) — 失败步骤: Run the non-SQL temporal backends under the skewed process zone

    packages/services/service-analytics test:  FAIL  src/__tests__/caller-content-admission-door.test.ts > [#21177] caller-content admission — the /analytics/query door > 'NativeSQLStrategy' > 'no securit
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    packages/services/service-analytics test:  FAIL  src/__tests__/caller-content-admission-door.test.ts > [#21177] caller-content admission — the /analytics/query door > 'NativeSQLStrategy' > 'admin — pr
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    packages/services/service-analytics test:  FAIL  src/__tests__/caller-content-admission-door.test.ts > [#21177] caller-content admission — the /analytics/query door > 'NativeSQLStrategy' > 'non-admin 
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    packages/services/service-analytics test:  FAIL  src/__tests__/caller-content-admission-door.test.ts > [#21177] caller-content admission — the /analytics/query door > 'ObjectQLStrategy' > 'no security
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    packages/services/service-analytics test:  FAIL  src/__tests__/caller-content-admission-door.test.ts > [#21177] caller-content admission — the /analytics/query door > 'ObjectQLStrategy' > 'admin — pro
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    packages/services/service-analytics test:  FAIL  src/__tests__/caller-content-admission-door.test.ts > [#21177] caller-content admission — the /analytics/query door > 'ObjectQLStrategy' > 'non-admin —
      ↳ 失败原因: packages/services/service-analytics test: AssertionError: expected Error: [Analytics] Access denied: member … { …(4) } to match object { code: 'INVALID_FIELD', …(3) }
    packages/services/service-analytics test:  FAIL  src/__tests__/field-read-admission-gate.test.ts > [#20965] the field-level read gate — a member that names no field is refused, never stood down > 'Nat
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    packages/services/service-analytics test:  FAIL  src/__tests__/field-read-admission-gate.test.ts > [#20965] the field-level read gate — a member that names no field is refused, never stood down > 'Obj
      ↳ 失败原因: packages/services/service-analytics test: AssertionError: expected Error: [Analytics] Access denied: member … { …(4) } to match object { code: 'INVALID_FIELD', …(2) }
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 4 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 55012df Oct 1, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21174-admin-audit-metadata branch October 1, 2026 16:38
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…e policies — double accumulation, the PostgreSQL boolean cast and the empty-sum fold, hoisted into core (objectstack-ai#21042) (objectstack-ai#21209)

Fixes objectstack-ai#21042
Clause-②: yes (widening)

## What this changes

The analytics native-SQL strategy (`NativeSQLStrategy`, the default on a
SQL driver) skipped three aggregate policies that
`SqlDriver.aggregate()` applies. So one route answered different
numbers, or a `500`, depending on which strategy served it. This PR
follows the route ruling on objectstack-ai#21042 (comment `5925613967`): the operand
policies are hoisted into `@objectstack/core`, beside
`AGGREGATE_ANSWER_KIND`, and both faces read them from there.

- **`packages/core/src/utils/aggregate-answer.ts`**
(`@objectstack/core`, `minor`). It takes:
- `AGGREGATE_ACCUMULATION`, moved from `driver-sql` with its docblock
(the docblock and the table are byte-identical to the base, apart from
the `export` keyword);
- `aggregandColumnClass({ type, multiple })`, the one column-class
predicate (`'fractional'`, `'integral'`, `'boolean'`, or none);
- `POSTGRES_BOOLEAN_AGGREGAND_CAST`, the objectstack-ai#11635 cast, as a `Record` over
`AggregationFunction`: `sum` / `avg` / `min` / `max` are cast, the
counts never are;
- `doubleAccumulationOperand(operand, dialect)`, the double operand with
the dialect as a parameter;
- `aggregandOperandSql(func, columnClass, dialect, operand)`, the one
composition both faces emit (the cast inside, the double operand around
it).
  No `index.ts` line was added: the module is already exported.
- **`packages/drivers/driver-sql/src/sql-driver.ts`** (`patch`), in the
ruled regions only. The `AGGREGATE_ACCUMULATION` table becomes a pointer
plus an import. `isFractionalNumericType` is deleted. The two registry
fills fill `fractionalNumericFields` through the predicate.
`accumulatesInDouble` / `doubleAccumulationOperand` are replaced by
`aggregandColumnClassOf`, which maps the driver's registries onto the
predicate's classes. In `aggregate()`, the private boolean-cast
condition and the accumulation call become one
`aggregandOperandSql(funcName, class, this.dialectName, '??')`.
-
**`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`**
(`patch`), in two places.
- **`resolveMeasureSql`** wraps the column it hands `AGGREGATE_SQL` /
`CONDITIONAL_AGGREGATE_SQL` in `aggregandOperandSql`. The column class
comes from the declaration the host already relays
(`declaredValueShape`), on the object the column lives on
(`columnObjectOf`, the one hop resolver). The dialect comes from
`sqlDialect`, which the strategy already reads.
- **The `execute` shaping point** that PR objectstack-ai#21040 added now folds a
`null` measure answer to `emptyGroupValueFor(measure.type)`
(`@objectstack/spec`). It does this for every measure, measure-scoped
ones included, before the number presenter, in `driver-sql`'s order. The
dataset door's `DatasetExecutor` fill stays, and it is idempotent on a
folded row.
- The one line outside those two regions is the `generateSql` call site,
which now passes `ctx` to `resolveMeasureSql`.
- No native copy of any policy, and no runtime hook asks the driver: the
rejected (C) route was not taken. `canHandle`, `buildFieldMeta`, the
hop-object sites, the filter / text-match rendering,
`analytics-service.ts` and `field-read-admission.ts` are untouched.

## The card's table, before and after

Measured through `AnalyticsService.query` (the cube door, which `POST
/api/v1/analytics/query` relays verbatim) and
`AnalyticsService.queryDataset` (the dataset door), on
`AnalyticsServicePlugin` over a real ObjectQL engine and `SqlDriver`.
**Native** is the plugin's own composition (`NativeSQLStrategy`
answered, with one raw statement and no engine aggregate). **ObjectQL**
is the same composition narrowed to `engine.aggregate`. "Before" is the
strategy file at the base `d34aa58a2a`; "after" is this branch at
`61aab5013a`. Neither merge since then touches the aggregate code paths,
and the pins below are green at `ef1f9d8484`.

Fixture:

- group `f`: `frac` (a `number` column) holds 0.1 and 0.2, and `flag`
holds true and false;
- group `i`: `stars` (a `rating` column) holds seven 1s and two 2s, and
`flag` holds 7 trues and 2 falses;
- group `n`: every aggregand is NULL in all three rows.

The measure-scoped measures filter on `tag = x`, which only group `f`
holds.

**PostgreSQL 16.13** (a private local server; the ObjectQL column is the
same before and after):

| measure | group | door | native before | native after | ObjectQL |
|:--|:--|:--|:--|:--|:--|
| `sum(frac)` | f | cube, dataset | `0.3` | `0.30000000000000004` |
`0.30000000000000004` |
| `avg(frac)` | f | cube, dataset | `0.15` | `0.15000000000000002` |
`0.15000000000000002` |
| `avg(stars)`, an integer column | i | cube, dataset |
`1.222222222222222` | `1.2222222222222223` | `1.2222222222222223` |
| `sum(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `7` | `7` |
| `avg(flag)` | i | cube, dataset | `500 DATABASE_ERROR` |
`0.7777777777777778` | `0.7777777777777778` |
| `min(flag)` / `max(flag)` | i | cube, dataset | `500 DATABASE_ERROR` |
`0` / `1` | `0` / `1` |
| `sum(frac)`, all-NULL group | n | cube | `null` | `0` | `0` |
| `sum(frac)`, all-NULL group | n | dataset | `0` (executor fill) | `0`
| `0` |
| `sum(flag)`, all-NULL group | n | cube | `500 DATABASE_ERROR` | `0` |
`0` |
| `avg(frac)`, all-NULL group | n | cube, dataset | `null` | `null` |
`null` |
| measure-scoped `sum(frac)`, no admitted row | i | cube | `null` | `0`
| `0` |
| measure-scoped `sum(frac)`, no admitted row | i | dataset | `0`
(executor fill) | `0` | `0` |
| measure-scoped `avg(frac)`, no admitted row | i | cube | `null` |
`null` | `null` |
| `count` control | n | cube, dataset | `3` | `3` | `3` |
| measure-scoped `count` control | i | cube, dataset | `0` | `0` | `0` |

**SQLite** (better-sqlite3): accumulation and the boolean answers
already agreed on every face (`0.30000000000000004`,
`0.15000000000000002`, `1.2222222222222223`, `7`, `0.7777777777777778`,
`0` / `1`). The fold is the policy that diverged there:

| measure | group | door | native before | native after | ObjectQL |
|:--|:--|:--|:--|:--|:--|
| `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | cube |
`null` | `0` | `0` |
| `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | dataset
| `0` (executor fill) | `0` | `0` |
| measure-scoped `sum(frac)`, no admitted row | i, n | cube | `null` |
`0` | `0` |
| measure-scoped `sum(frac)`, no admitted row | i, n | dataset | `0`
(executor fill) | `0` | `0` |

After the fix, the native and ObjectQL faces **differ in 0 of 144
cells** (2 drivers × 2 doors × 12 measures × 3 groups).

**MySQL is NOT MEASURED**: there is no MySQL server in this container.
The MySQL operand text is pinned offline: by `core`'s
`aggregate-answer.test.ts`, and by the `driver-sql` move proof for the
driver's own statements.

## The move proof

`driver-sql`'s aggregate statements were dumped at the base, before any
consumer changed. The dump covered `SqlDriver.aggregate()` for every
function (`count`, `count_distinct`, `sum`, `avg`, `min`, `max`, and
`count(*)`), aliased and unaliased, over 23 columns: every fractional,
integral and boolean type, the `float` / `integer` / `int` aliases,
multi-valued and untyped columns, and text / date / lookup / formula. It
ran on SQLite, PostgreSQL and MySQL, through both registration paths
(`registerObjectMetadata` and `registerExternalObject`), offline (knex
`toSQL()`).

The policies were then hoisted, `driver-sql` was switched to the
imports, and the same dump was run again:

- base dump: 1668 entries, 0 errors, md5
`8eee668372a28a7568f3eb1cc5a2bc9b`;
- after dump (at `bc8aa0cc2f`): 1668 entries, md5
`8eee668372a28a7568f3eb1cc5a2bc9b`. `cmp` printed nothing: the two dumps
are **byte-identical**.

`sql-driver.ts` and `aggregate-answer.ts` are unchanged between
`bc8aa0cc2f` and `61aab5013a`.

The committed move-proof pin,
`packages/drivers/driver-sql/src/sql-driver-21042-aggregate-policy-move.test.ts`,
holds the captured expressions for one column of each class, on each
dialect and through each registration path. It passed at the base
(`192fc0010b`: 54 / 54) and passes after (54 / 54).

## Pins (committed red first, then the fix)

| file | at the pins commit (`192fc0010b`, base code) | after |
|:--|:--|:--|
| `core` `aggregate-answer.test.ts` | 16 red (the exports did not exist)
| 22 / 22 |
| `service-analytics` `native-sql-aggregate-policies.test.ts` (each
measure on both faces at both doors, against the engine's arithmetic;
SQLite and live PostgreSQL cells) | SQLite: the cube-door folds red.
PostgreSQL: accumulation, boolean `500`, folds red | 49 / 49 |
| `service-analytics` `cube-measure-field-type-door.test.ts`, **the
lifted skip** | PostgreSQL native `max(boolean)` red (`500`) | 23 / 23 |
| `rest` `analytics-dataset-aggregate-policies-door.test.ts` (the route,
both strategies) | PostgreSQL: 7 red (accumulation and booleans). SQLite
green (that door already folded) | 19 / 19 |
| `driver-sql` move proof | 54 / 54 | 54 / 54 |

**The lifted skip:** `it.skipIf(cell.id === 'pg' && face === 'native')`
in `cube-measure-field-type-door.test.ts` (from PR objectstack-ai#21128) is gone. Its
comment now says why the cell runs on every cell and face. The
PostgreSQL native `max_flag` cell answers `1`.

`native-sql-measure-number-presentation.test.ts` gets a comment-only
edit: its header said the native statement does not carry objectstack-ai#20387's
accumulation, and it now points at the new pin.

## Ablations

There was one ablation per policy, each predicted in writing before it
ran. Each mutation was planted through `scripts/ablation-replace.mjs`,
which checks that the anchor hit and that the blob changed. Each
mutation was confirmed in the built `dist/`
(`ablation-dist-preflight.mjs`: marker present). Each restore ran by
absolute path (`git checkout HEAD`), and the file's blob was proven
equal to its `HEAD` blob with `git diff HEAD` empty. After each restore
the package was rebuilt, and the marker was proven absent from `dist/`
with the tree clean. Every prediction held exactly.

| ablation | mutation | predicted red | observed red |
|:--|:--|:--|:--|
| A1 accumulation | `core` `accumulatesInDouble`'s dialect gate never
admits PostgreSQL or MySQL | `core` 3; `driver-sql` move proof 24 (pg
and mysql × both fills × the six numeric / boolean columns);
`service-analytics` 10, PostgreSQL only (both doors × `sum` /
`avg(frac)`, `avg(stars)`, measure-scoped `sum` / `avg`); `rest` 3,
PostgreSQL only | the same 3 / 24 / 10 / 3; SQLite cells green;
`avg(flag)` green as predicted |
| A2 boolean cast | `core` `aggregandOperandSql` never casts | `core` 1;
move proof 4 (pg × both fills × `boolean` / `toggle`);
`service-analytics` 8, PostgreSQL only; the lifted cell, PostgreSQL
native and ObjectQL, 2; `rest` 4, PostgreSQL only | the same 1 / 4 / 8 /
2 / 4 |
| A3 fold | the native shaping point never folds | `service-analytics`
8, cube door only (SQLite and PostgreSQL × the three all-NULL `sum`s and
the measure-scoped `sum`); everything else green, the `rest`
dataset-door route included, because the executor fill folds there | the
same 8; `rest` 19 / 19 green |

A1 and A2 show one policy reaching both faces. Each one turned
`driver-sql`'s own statements red. Under A2 the ObjectQL face's
`max(boolean)` cell failed too, with the driver's refusal. Under A1 the
ObjectQL face answered the same exact decimal as the native face for the
plain measures: the failing assertion was the engine's number, while
native and ObjectQL still agreed.

A **reverse type check** also ran. Passing a dialect the new type
rejects (`'oracle'`) to `aggregandOperandSql` turned
`service-analytics`' typecheck red (`TS2345 ... not assignable to
parameter of type 'AggregandSqlDialect'`), which shows the rebuilt
`core` `.d.ts` was read. The file was restored byte-identical.

## Verification (at `ef1f9d8484`, after merging `origin/main` at
`cb45469e67`, which carries PR objectstack-ai#21170 and PR objectstack-ai#21173)

Everything below ran as one locked script, at `ef1f9d8484`, with each
exit code captured before any pipe. The live PostgreSQL 16.13 server ran
at `timezone = Asia/Shanghai`, and the `driver-sql` suite ran under
`TZ=America/New_York`, which are its own non-vacuity preconditions.

- **Refresh after the merge:** `pnpm turbo run build
--filter='!@objectstack/docs' --concurrency=1` exit 0, and `pnpm
--filter @objectstack/spec check:generated` exit 0.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 67 commands from the real diff (10
paths). All 67 exited 0. Reconciliation with `--ran` and the recorded
exit codes printed: `Run reconciliation — 67 derived, 67 run, 0
NOT-MEASURED, 0 UNRUN.`
- **Typecheck:** `pnpm --filter … typecheck` exit 0 for
`@objectstack/core`, `@objectstack/driver-sql`,
`@objectstack/service-analytics` and `@objectstack/rest`.
- **Tests, every vitest project of every touched package** (`vitest run
--maxWorkers=2`, with `OS_TEST_POSTGRES_URL` set so the live cells ran):

| package / project | files | tests |
|:--|:--|:--|
| `core` local | 74 passed | 2120 passed |
| `core` repo | 3 passed | 48 passed |
| `driver-sql` | 216 passed, 3 skipped | 4300 passed, 96 skipped |
| `service-analytics` | 161 passed | 3765 passed |
| `rest` local | 256 passed | 5027 passed, 127 skipped |
| `rest` repo | 5 passed | 179 passed, 1 skipped |

- **The five pin files, run explicitly:** move proof 54 / 54, `core` 22
/ 22, policies 49 / 49 (24 SQLite + 24 PostgreSQL + the oracle),
field-type door 23 / 23, `rest` route 19 / 19 (9 SQLite + 9 PostgreSQL +
the oracle).
- **Lint, narrowed and proven:** `eslint --no-inline-config --format
json` over the 9 changed code files answered 9 results, 0 errors and 0
warnings. The population is read from eslint's own config:
`ESLint.isPathIgnored` answers `false` for each of the 9. The narrowing
excludes nothing that could move, because `eslint.config.mjs` never
enables type-aware linting (no `parserOptions.project`, no typed rules),
so this diff cannot change the verdict on an untouched file. The
repo-wide `pnpm lint` is CI's.
- **The `driver-sql` live preconditions:** the first gate run used a
private server at UTC, and the suite's four timezone non-vacuity cells
failed by design (`… start it with timezone=Asia/Shanghai`). With the
server at `Asia/Shanghai` and the process at `America/New_York` the
suite is green, as listed above.
- **`main` after the final merge:** `origin/main` moved 4 commits past
`cb45469e67` before this PR opened (objectstack-ai#21149, objectstack-ai#21188, objectstack-ai#21195, objectstack-ai#21192).
None of them touches `core`, `driver-sql`, `service-analytics` or the
analytics `rest` tests. The one `packages/spec` file in the analytics
area, `ui/dataset.zod.ts`, changes a comment only. They are not merged
here; CI runs on the merge ref.

## Acceptance notes

- **MySQL is NOT MEASURED** (no server in this container). The MySQL
operand text is pinned offline in `core` and in the `driver-sql` move
proof.
- **The live PostgreSQL cells are not run in CI.** No CI step sets
`OS_TEST_POSTGRES_URL` for `service-analytics` or `rest`. The cells
above ran against a private PostgreSQL 16.13 started for this run and
removed afterwards. In CI the SQLite cells run, and so do the offline
`core` / move-proof pins.
- **Phase 0's note on the dataset door, which is no divergence:** a
measure-scoped `avg` is **absent** from a row its supplementary query
reported no row for. That is `x_avg_frac` for groups `i` and `n`, on
both strategies and before and after. It is not `null`. The new service
pin holds this cell only to "both faces agree", not to a value.
Relatedly, a dataset-door selection made only of measure-scoped measures
reports only the groups their filter admits, so the pin asks each one
beside the base count.
- **Residual, as stated in the ruling:** a host that relays no field
declarations (`declaredValueShape`), or names no SQL dialect, gets no
column class or no policy. It keeps the native arithmetic it had, and a
PostgreSQL boolean `sum` there still answers `500`. The plugin's own
composition wires both.
- **How `driver-sql` reads the class:** it reads its own registries
rather than calling the predicate per column. `fractionalNumericFields`
is filled by the predicate. `booleanFields` and `numericFields` are
filled by the driver's coercion rules, whose populations equal the
predicate's `'boolean'` and `'fractional'` ∪ `'integral'` classes. The
move proof pins that equality per column class. Asking the predicate per
column through the driver's `valueShapeFields` would retire
`fractionalNumericFields`, but its declaration and shard-alias regions
are outside the ruled surface, so this PR does not do it.
- **The scan-order residual is unchanged.** On PostgreSQL and MySQL the
double sums are added without compensation (`AGGREGATE_ACCUMULATION`'s
docblock), so three or more fractions can still differ in the last place
from SQLite and the rows path. The pins use two addends.
- objectstack-ai#21129 (the presenter for a relationship-path `min` / `max`) is not
addressed here.

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

---------

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/l tests tooling

Projects

None yet

2 participants