Skip to content

Commit f6199a8

Browse files
refactor(objectql): retire the four engine.ts facade-gate exception rows the protocol.ts split handed over (#31343)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XG6A5LoAmSdVHfBMCv1uaVK)_ Fixes #30975 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:#30147 交过来的四个 `engine.ts` facade 门禁例外行全部退役。`homeAmountServing` 的方法体用 codemod 搬进 references facet;`projectHomeAmountsOntoPatch` 类上存根不再算默认值,改由 facet 算;`ScopedContext` 的 `derive` 与 `[APP_AUTHORED_HOOK_API]` 的方法体搬进 `ScopedContextFacet`,对新实例私有字段 `appAuthored` 的那一次写改走该实例自己的内部 host setter。 - 为什么改:facade 门禁要求 `engine.ts` 里的类只剩一行委托;这四行是登记在案的欠账,登记表里 `card: #30975` 的行现在为零。 - 风险与回滚:纯内部搬移,行为不变(objectql 全量 vitest 基线与 head 都是 565 文件 / 11,428 测试全绿);公开声明不变(`.d.ts` 只少了两个私有成员的注释块,声明行零差异);回滚即 revert 本 PR。 - 附带:`content/docs/permissions/system-context.mdx` 第 1b 行的 Symbol 键由 `inheritsElevation` 改为 `appAuthoredHookApi`,站点登记表用 `--write` 重新生成(`check:system-context-census` 按符号定位 `isSystem` 读点,方法体搬家后键随之移动)。 - 先看:`packages/objectql/src/engine-scoped-context.ts` 新增的 `derive` / `appAuthoredHookApi`(`scopedContextHost.of(child).appAuthored = …` 那两行);`engine.ts` 中 `ScopedContext` static block 新增的 `set appAuthored`;`engine-references-and-master-detail.ts` 的 `homeAmountServing`。 - 两分钟验证:`pnpm check:facade-delegation-only`,预期末行 `5 facade class(es) in 3 file(s) hold delegations only, 11 named exception(s).`,退出码 0。 - 需要你做的:无,席位落地。 ## What changes All four exception rows that #30147's ruling (6687801899, option A) handed to this card retire, and `scripts/facade-delegation-registry.json` now holds no row with `card: "#30975"` (15 → 11 exceptions). **Row 1, `ObjectQL.homeAmountServing`.** The body moves into `ReferencesAndMasterDetailFacet` by the codemod's own `--members` pass, unedited. On `main` the pass refuses it with `field-call: homeLocalizationResolver`. For this move only, that one stay reason was lifted for that one field in a local, uncommitted copy of `scripts/codemod-split-engine.mjs` (a one-line condition; the committed script is unchanged and no general field-forwarding rule is added). That run's own undo proof rebuilds the base `engine.ts` and the facet module byte for byte. The host already carried `homeLocalizationResolver` (getter and setter), `find` and `_registry`, so the host factory does not grow. Three import bindings move from `engine.ts` to the facet with the body (`EXCHANGE_RATE_OBJECT`, `memoizeHomeAmountServing`, `HomeLocalization`). The one runtime difference is the resolver's receiver: `this.homeLocalizationResolver?.(organizationId)` now calls it with the host as `this`, not the engine. `HomeLocalizationResolver` is a plain function type of `organizationId` with no `this` parameter, and all 14 installations in the tree (one in `plugin.ts`, the rest in tests) are arrow functions, so nothing reads it. **Row 2, `ObjectQL.projectHomeAmountsOntoPatch`.** The body already lived in the facet (#29986), and its facet signature already defaults `serving` to `this.homeAmountServing(context)`. The class stub's parameter is now `serving?: HomeAmountServing` and is passed through as given, so the facet computes the default when the caller passes none. It still reaches the engine's `homeAmountServing` through the host closure, so an instance spy or a subclass override is still the one called. The only caller (the update path) passes none. **Rows 3 and 4, `ScopedContext.derive` and `ScopedContext[APP_AUTHORED_HOOK_API]`.** The order asked first whether a design moves the foreign-private `appAuthored` write off the class without widening anything public. One does, and it is small: - Both bodies move into `ScopedContextFacet` (as `derive` and `appAuthoredHookApi`). Each builds a `ScopedContext` and writes the new instance's private `appAuthored` through **that instance's own host**: `scopedContextHost.of(child).appAuthored = this.appAuthored` and `scopedContextHost.of(api).appAuthored = true`. - The host is the port the class's static block already fills for every facet body. It gains one setter, `set appAuthored(v) { instance.appAuthored = v; }`, which is the codemod's own shape for a field a body assigns, and `ScopedContextHost.appAuthored` loses `readonly`. `derive` is private, so its docblock moves with its body; `[APP_AUTHORED_HOOK_API]` is public and keeps its docblock on the class. - **Where the setter lives and who can reach it.** The setter is one getter/setter pair inside the host object literal that `ScopedContext`'s static block hands to `scopedContextHost.install(...)` (`packages/objectql/src/engine.ts`, the `[#30052 codemod] Installs the host of ScopedContextFacet` block). The only way to get a host is `scopedContextHost.of(instance)`, and `scopedContextHost` is exported only from `packages/objectql/src/engine-scoped-hosts.ts`. Measured with `git grep -n "engine-scoped-hosts" -- packages examples apps`: exactly two modules import it, `engine.ts` (line 426) and `engine-scoped-context.ts` (line 44); the other four hits are comments. Neither `packages/objectql/src/index.ts` nor `src/core.ts` re-exports either module, the package's `exports` map has only `.` and `./core`, and `scopedContextHost`, `InstanceHost` and `ScopedContextHost` appear 0 times in the built `index.d.ts`, `core.d.ts` and the shared chunk. So nothing outside the `engine*.ts` modules can reach the setter through the package. - Nothing public widens: no new class member, no new constructor parameter, no exported setter, and the built declarations are unchanged (see Acceptance notes). TypeScript's `private` was never a runtime barrier, so the setter grants no capability that in-process code lacked. - `engine.ts` no longer reads `appAutomationElevationOf` or `markAppAutomationElevation`, so those two import bindings move to the facet module, which already imported them. Rows 3 and 4 therefore do not need an accepted-exception ruling, and this PR writes no exception reason. **One docs cell and the generated register.** `check:system-context-census` keys every `isSystem` read by the symbol that holds it. On `main` the app-authored api's read (`const inheritsElevation = ctx.isSystem && …`) sat in a symbol-keyed method, so the census keyed it `engine.ts#inheritsElevation`, and row 1b of `content/docs/permissions/system-context.mdx` named `inheritsElevation`. In the facet the read lives in `appAuthoredHookApi`, so the gate went red with `[row-key-no-read]` and `[read-without-a-row]`. Row 1b's Symbol cell now names `appAuthoredHookApi` (`elevationSource` kept), and `node scripts/check-system-context-census.mjs --write` regenerated the site register (one line out, one line in). No prose changes. The claim amendment 6716417615 covers this file. **A patch changeset** for `@objectstack/objectql`: the package's built JavaScript moves between modules inside the bundle, while its declarations do not. `engine.ts` goes from 10,712 to 10,653 lines. 6 files, +118 / -104. ## Tests No test file changes; this is a move, and the existing suites pin the moved behaviour. Measured on `b2ef226ef`; the two commits after it (the changeset and the docs page) touch no source: - `@objectstack/objectql` vitest (`--project local`): base `604a2b3b9` **565 files / 11,428 tests passed**; head **565 files / 11,428 tests passed**. `pnpm --filter @objectstack/objectql typecheck` (tsc plus `check:test-typecheck`) exit 0. - **Ablation, `derive`'s write** (`ablation-replace.mjs`, anchor `scopedContextHost.of(child).appAuthored = this.appAuthored;` replaced by `… = !1 && this.appAuthored;`; mutation landed, anchor 1 → 0): `recycle-bin/purge.test.ts` + `hook-run-as.test.ts` went **6 failed / 62 passed**, among them "is refused the same way on the api `sudo()` derives" and the four `runAs` permanent-delete refusals. Restored: blob equal to `HEAD`, `git diff HEAD` empty. - **Ablation, the app-authored mark** (anchor `scopedContextHost.of(api).appAuthored = true;` replaced by `… = !1;`): the same two files went **9 failed / 59 passed**. Restored the same way. The head control run of both files: 68/68. - Those suites import `src/` (`../engine.js`), so the ablations needed no rebuild. ## Acceptance notes - **The `.d.ts` bar.** `@objectstack/objectql` built at base and at head: `index.d.ts`, `index.d.mts`, `core.d.ts` and `core.d.mts` have zero differing lines once the shared chunk's file name is normalised. The chunk itself (`util-*.d.ts` / `.d.mts`) differs only by **16 comment lines and 0 declaration lines**: the JSDoc blocks of the two private members whose bodies moved (`homeAmountServing`'s 15 lines and `derive`'s one line), which leave with their bodies under the codemod's private-docblock rule (the same rule #30053 applied). `private homeAmountServing;`, `private projectHomeAmountsOntoPatch;`, `private appAuthored;`, `private derive;` and `[APP_AUTHORED_HOOK_API](): ScopedContext;` are printed as before. Because the chunk's content changed, its hashed file name changed too (`util-DGHGF_i8` → `util-BfmsHmSX`). So the public surface is unchanged, but the declaration files are not byte-identical: they differ by those two private comments and the chunk name. This is stated here, not hidden; keeping the comments on the class would break the codemod's undo proof for row 1. - **Control for the gate.** `check-facade-delegation-only.mjs --root BASE_TREE --registry HEAD_REGISTRY` exits 1 with exactly four problems, one per retired member, so each row was load-bearing and each move is what turns it green. - **Reproducing the codemod move.** With the one-line lift applied locally, `node scripts/codemod-split-engine.mjs --members --verify --base 604a2b3` on `aba639da1` exits 0. Without the lift it exits 1 with exactly one failure, the stay rule itself (`homeAmountServing: a stay rule holds it at base (field-call: homeLocalizationResolver)`). - **Serial check.** Open pulls touching `packages/objectql/src/engine*.ts` at 02:13Z: only #31220 (engine.ts hunks at about lines 86, 2443 and 8360). `git merge-tree --write-tree HEAD pr/31220` exits 0, with no conflict. ## Verification <details><summary>Codemod move and undo proof (row 1)</summary> The one-off lift, applied only for the run (not committed): ```text - if (called) add('field-call', member); + if (called && member !== 'homeLocalizationResolver') add('field-call', member); ``` `node scripts/codemod-split-engine.mjs --members homeAmountServing --write`: exit 0. ```text - engine-references-and-master-detail.ts: homeAmountServing (39 body lines, its docblock with it) output tree sha256: 9d3b18d56488bab56bbfca02ff673ac4ee1eb6f1f9615d5202b285b0c71f065d (input sha256 b612db91716697a245085be30d0cdbc41870c0c215519421dbc5b0bd34067f0b) undo proof: 1 moved method(s) (homeAmountServing); engine.ts 10712 -> 10659 lines; undoing the moves does rebuild the base engine.ts and its modules byte-for-byte once the 3 import binding(s) only moved bodies read are pruned from the base (EXCHANGE_RATE_OBJECT, HomeLocalization, memoizeHomeAmountServing) ``` `--members --verify --base 604a2b3` with the lift: exit 0, the same proof line. Without the lift (the committed script): exit 1, the one stay-rule failure only. </details> <details><summary>Facade gate</summary> `node scripts/check-facade-delegation-only.mjs` at head: exit 0, `✓ check-facade-delegation-only: 5 facade class(es) in 3 file(s) hold delegations only, 11 named exception(s).` Control, base code with the head registry: exit 1, four problems: ```text ✗ packages/objectql/src/engine.ts:2563 ObjectQL.homeAmountServing holds a body, not a delegation: statement 1 of 3 (VariableStatement) is not a forwarding declaration (3 statement(s)). ✗ packages/objectql/src/engine.ts:2625 ObjectQL.projectHomeAmountsOntoPatch holds a body, not a delegation: parameter `serving` computes its default. ✗ packages/objectql/src/engine.ts:10270 ScopedContext.derive holds a body, not a delegation: statement 1 of 3 (VariableStatement) is not a forwarding declaration (3 statement(s)). ✗ packages/objectql/src/engine.ts:10316 ScopedContext.[APP_AUTHORED_HOOK_API] holds a body, not a delegation: statement 2 of 5 (VariableStatement) is not a forwarding declaration (5 statement(s)). ``` </details> <details><summary>Suites</summary> Under the shared lock: base `vitest run --project local --maxWorkers=2` VERDICT exit 0, Test Files 565 passed (565), Tests 11428 passed (11428). Head: VERDICT exit 0, Test Files 565 passed (565), Tests 11428 passed (11428). `pnpm --filter @objectstack/objectql typecheck` VERDICT exit 0. `pnpm --filter @objectstack/objectql build` exit 0 at base and at head (after `--filter '@objectstack/objectql^...' build`, exit 0). `eslint --no-inline-config` on the three changed source files: exit 0. </details> <details><summary>.d.ts base vs head</summary> `pnpm --filter @objectstack/objectql build` at `604a2b3b9` and at `b2ef226ef`. With the shared chunk's hashed name normalised: `index.d.ts`, `index.d.mts`, `core.d.ts`, `core.d.mts` diff exit 0. The chunk (`util-DGHGF_i8.d.ts` → `util-BfmsHmSX.d.ts`, and the same for `.d.mts`): 16 changed lines, 0 of them outside a comment. The whole diff (normal `diff` output, its `<` markers spelled `-` here): ```text 7471,7485d7470 - /** - * [ADR-0144 D5, Open item 2] What one write's home projection reads, memoized - * for that write: each organization's home currency once, and each - * (organization, currency, document date) rate once, however many rows ask. - * - * The rate is the `sys_exchange_rate` row of that currency with the latest - * `effective_date` on or before the document date, in the organization the - * row lands in. Read `sudo()`-shaped (`{ ...context, isSystem: true }`): a - * rank-and-file writer cannot read the rate table (parameter 2 makes it an - * administrator's object), yet every write of theirs is converted through - * it; the caller's transaction rides along, and `tenantId` is the row's own - * organization, so one organization's rate never converts another's - * amount. An install without the rate object registered has no rate for any - * currency. - */ 9072d9056 - /** A child over `executionContext` that keeps this api's provenance. */ ``` </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived three times as the diff grew (source, then the changeset, then the docs page). Every family run with stdin closed and its exit code captured before any pipe; the source-only families ran at `15827c138`, the changeset and docs families and the two reruns at `7d5f631fc` (the docs commit touches only the `.mdx` page). Reconciliation at `7d5f631fc`: `node scripts/pm/dispatch-gates.mjs --ran 30975-gates-ran.txt`, exit 0, "103 derived, 101 run, 2 NOT-MEASURED, 0 UNRUN". - NOT MEASURED (exit 3, prerequisite not met in a card worktree, which lacks every package's `dist/`): `pnpm check:dual-build-cjs-loads`, `pnpm check:published-readme-exports`. CI runs both. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first answered exit 3 on the shallow clone; after `git fetch --depth=1 origin 621a487` it exits 0. - `pnpm check:system-context-census` was exit 1 before the row 1b re-key (above) and is exit 0 after it. - `pnpm --filter @objectstack/spec run check:skill-examples` first answered exit 3 (no client SDK `dist/`); after building `@objectstack/client-react` and its closure it exits 0. ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-engine-split-ratio.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:error-code-provenance :: exit 0 pnpm check:adr-symbol-anchors :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:durability-log-level :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:facade-delegation-only :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:object-def-param-keys :: exit 0 pnpm check:optional-error-sink :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-exports :: exit 3 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:scripts-symbol-anchors :: exit 0 pnpm check:settings-bind-window :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:swallow-census-controls :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:vendor-version-stamps :: exit 0 ``` </details> Session: https://claude.ai/code/session_01FEkQ5kF4yD4vMatZJ2chQM 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e735425 commit f6199a8

6 files changed

Lines changed: 118 additions & 104 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
refactor(objectql): four more `engine.ts` bodies move into their facets
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- `ObjectQL#homeAmountServing` moves into `ReferencesAndMasterDetailFacet` unchanged. Its one call of the home-localization resolver now goes through the facet's host, so the resolver is called with the host as its receiver instead of the engine. The resolver type takes no `this`, and the only one the platform installs is an arrow function, so nothing reads that receiver.
11+
- `ObjectQL#projectHomeAmountsOntoPatch` keeps its body in the facet. The class method now passes `serving` through as given, and the facet computes the default when the caller passes none, as the class did before.
12+
- `ScopedContext`'s private `derive` and its `[APP_AUTHORED_HOOK_API]` method move into `ScopedContextFacet`. Each builds a new `ScopedContext` and sets that instance's private `appAuthored` through the instance's own internal host, which is not exported. A derived api still keeps the app-authored mark, and an app-authored hook's api still refuses a permanent delete.
13+
- No behaviour changes and nothing to do on upgrade. The built declaration files are unchanged; the JavaScript moves between modules inside the bundle.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ bugs — it is the protection or the side effect that silently does not happen.
100100
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Symbol |
101101
|:--|:---|:---|:---|:---|
102102
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `registerAuthzMiddleware` |
103-
| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, an app-authored hook's (`runAs: 'system'`, a system context it inherits, its `sudo()`) and an action body's; the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation, by the action body's execution-context builder and `ObjectRepository.execute`, and on the api of a hook bound through the hook binder, whose inherited system context and `sudo()` it marks), at any depth of their writes, is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context with neither (the seeders, better-auth and its reconciliation, invitation placement, platform code's `sudo()`, which never sets the mark, and a hook registered in code) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook carries no `isSystem`, so the D12 gate judges it as the user it acts for | `elevationSource`, `inheritsElevation` |
103+
| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, an app-authored hook's (`runAs: 'system'`, a system context it inherits, its `sudo()`) and an action body's; the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation, by the action body's execution-context builder and `ObjectRepository.execute`, and on the api of a hook bound through the hook binder, whose inherited system context and `sudo()` it marks), at any depth of their writes, is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context with neither (the seeders, better-auth and its reconciliation, invitation placement, platform code's `sudo()`, which never sets the mark, and a hook registered in code) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook carries no `isSystem`, so the D12 gate judges it as the user it acts for | `elevationSource`, `appAuthoredHookApi` |
104104
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `registerAuthzMiddleware` |
105105
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `getReadFilter` |
106106
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `resolveProjectionFieldMask` |
@@ -543,10 +543,10 @@ and CI refuses one that differs from what the tree produces.
543543
| `packages/objectql/src/engine-referential-integrity.ts#masterDetailHeadersCallerReads` | 1 | 29c |
544544
| `packages/objectql/src/engine-referential-integrity.ts#referencesCheckedBeforeRules` | 1 | 23 |
545545
| `packages/objectql/src/engine-referential-integrity.ts#storedReferencesCallerReads` | 1 | 23 |
546+
| `packages/objectql/src/engine-scoped-context.ts#appAuthoredHookApi` | 1 | 1b |
546547
| `packages/objectql/src/engine-secret-internal-fields.ts#encryptSecretFields` | 1 | 23e |
547548
| `packages/objectql/src/engine-secret-internal-fields.ts#stripSearchCompanionFromRead` | 1 | 27 |
548549
| `packages/objectql/src/engine-validate.ts#validate` | 1 | 21b |
549-
| `packages/objectql/src/engine.ts#inheritsElevation` | 1 | 1b |
550550
| `packages/objectql/src/engine.ts#insert` | 3 | 21, 22 |
551551
| `packages/objectql/src/engine.ts#isSystem` | 1 | 64 |
552552
| `packages/objectql/src/engine.ts#update` | 4 | 19, 20 |

‎packages/objectql/src/engine-references-and-master-detail.ts‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
* [#29986 codemod] The
55
* "Realtime, i18n, home amounts, validation messages, references, master-detail"
66
* section of `ObjectQL`: the bodies of `setRealtimeService`, `setI18nService`,
7-
* `setHomeLocalizationResolver`, `homeAmountOrganizationOf`,
8-
* `previewHomeAmounts`, `projectHomeAmountsOntoPatch`, `referenceExists`,
7+
* `setHomeLocalizationResolver`, `homeAmountServing`,
8+
* `homeAmountOrganizationOf`, `previewHomeAmounts`,
9+
* `projectHomeAmountsOntoPatch`, `referenceExists`,
910
* `callerReadableReferenceExists`, `resolveMasterDetailParent`,
1011
* `resolveMasterDetailParents`, `materializeParentHeader`,
1112
* `masterDeclaredFields`, `inspectDanglingReferences`, `inspectCascadeDelete`,
@@ -46,8 +47,8 @@ import type { IRealtimeService } from '@objectstack/spec/contracts';
4647
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
4748
import type { ExecutionContext } from '@objectstack/spec/kernel';
4849
import { postureUsesUnionScope } from '@objectstack/spec/security';
49-
import { homeAmountsTouched, planHomeAmounts, projectHomeAmounts } from './home-amount-projection.js';
50-
import type { HomeAmountPlanEntry, HomeAmountReads, HomeAmountServing, HomeLocalizationResolver } from './home-amount-projection.js';
50+
import { EXCHANGE_RATE_OBJECT, homeAmountsTouched, memoizeHomeAmountServing, planHomeAmounts, projectHomeAmounts } from './home-amount-projection.js';
51+
import type { HomeAmountPlanEntry, HomeAmountReads, HomeAmountServing, HomeLocalization, HomeLocalizationResolver } from './home-amount-projection.js';
5152
import { resolveMasterDetailRelation } from './master-detail.js';
5253
import { materializeDeclaredFields } from './declared-fields.js';
5354
import { collectCascadeDeleteObjects } from './engine-delete-cascade.js';
@@ -109,6 +110,61 @@ export class ReferencesAndMasterDetailFacet {
109110
this.homeLocalizationResolver = resolver;
110111
}
111112

113+
/**
114+
* [ADR-0144 D5, Open item 2] What one write's home projection reads, memoized
115+
* for that write: each organization's home currency once, and each
116+
* (organization, currency, document date) rate once, however many rows ask.
117+
*
118+
* The rate is the `sys_exchange_rate` row of that currency with the latest
119+
* `effective_date` on or before the document date, in the organization the
120+
* row lands in. Read `sudo()`-shaped (`{ ...context, isSystem: true }`): a
121+
* rank-and-file writer cannot read the rate table (parameter 2 makes it an
122+
* administrator's object), yet every write of theirs is converted through
123+
* it; the caller's transaction rides along, and `tenantId` is the row's own
124+
* organization, so one organization's rate never converts another's
125+
* amount. An install without the rate object registered has no rate for any
126+
* currency.
127+
*/
128+
static homeAmountServing(this: Pick<ReferencesAndMasterDetailHost, '_registry' | 'find' | 'homeLocalizationResolver'>, context: ExecutionContext | undefined): HomeAmountServing {
129+
// One settings read per organization per write answers both the home
130+
// currency and the timezone.
131+
const localizations = new Map<string, Promise<HomeLocalization | undefined>>();
132+
const localizationOf = (organizationId: string | undefined) => {
133+
const key = organizationId ?? '';
134+
let hit = localizations.get(key);
135+
if (!hit) {
136+
hit = Promise.resolve(this.homeLocalizationResolver?.(organizationId));
137+
localizations.set(key, hit);
138+
}
139+
return hit;
140+
};
141+
return memoizeHomeAmountServing({
142+
homeCurrency: async (organizationId) => (await localizationOf(organizationId))?.currency,
143+
timezone: async (organizationId) => (await localizationOf(organizationId))?.timezone,
144+
rateOn: async (organizationId, currency, onOrBefore) => {
145+
if (!this._registry.getObject(EXCHANGE_RATE_OBJECT)) return undefined;
146+
const rows = await this.find(EXCHANGE_RATE_OBJECT, {
147+
where: { currency, effective_date: { $lte: onOrBefore } },
148+
orderBy: [{ field: 'effective_date', order: 'desc' }],
149+
limit: 1,
150+
fields: ['rate', 'effective_date'],
151+
context: {
152+
...(context ?? {}),
153+
isSystem: true,
154+
...(organizationId !== undefined ? { tenantId: organizationId } : {}),
155+
} as ExecutionContext,
156+
});
157+
const row = rows[0] as { rate?: unknown; effective_date?: unknown } | undefined;
158+
if (!row) return undefined;
159+
const effective = row.effective_date;
160+
return {
161+
rate: row.rate,
162+
effectiveDate: effective instanceof Date ? effective.toISOString().slice(0, 10) : (typeof effective === 'string' ? effective : undefined),
163+
};
164+
},
165+
});
166+
}
167+
112168
/**
113169
* [ADR-0144 D5] The organization a written row belongs to, for its home
114170
* projection: the row's own `organization_id`, else the writer's active

‎packages/objectql/src/engine-scoped-context.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
* `sudo`, `withRunAs`, `releaseJoinedHandle`, `txDriver`, `beginTransaction`,
77
* `commitTransaction`, `rollbackTransaction`, `serveFormulaField` moved out of
88
* `engine.ts` whole by `scripts/codemod-split-engine.mjs` (its second pass).
9+
* The bodies of `derive` and `[APP_AUTHORED_HOOK_API]` moved by hand
10+
* (#30975): each writes the private `appAuthored` of the instance it builds,
11+
* which the codemod refuses to move (`foreign-private`), so each writes it
12+
* through that instance's own host setter.
913
*
1014
* A MOVE, not a redesign. {@link ScopedContextFacet} is a stateless facet of
1115
* `ScopedContext`: each static method of it is the body of the `ScopedContext`
@@ -32,12 +36,14 @@
3236

3337
import { driverSupportsTransactions } from '@objectstack/spec/data';
3438
import type { IScopedContext } from '@objectstack/spec/contracts';
39+
import type { ExecutionContext } from '@objectstack/spec/kernel';
3540
import { appAutomationElevationOf, markAppAutomationElevation } from '@objectstack/core';
3641
import { UnscopedHookApi } from './hook-run-as.js';
3742
import { refuseAppAuthoredPermanentDelete } from './recycle-bin/app-authored-delete.js';
3843
import type { HookRunAs, HookRunAsRef } from './hook-run-as.js';
39-
import { ObjectRepository } from './engine.js';
40-
import type { ObjectQL, ScopedContext } from './engine.js';
44+
import { scopedContextHost } from './engine-scoped-hosts.js';
45+
import { ObjectRepository, ScopedContext } from './engine.js';
46+
import type { ObjectQL } from './engine.js';
4147

4248
/**
4349
* The `ScopedContext` members the bodies of {@link ScopedContextFacet} read —
@@ -47,7 +53,7 @@ import type { ObjectQL, ScopedContext } from './engine.js';
4753
* are fields a body assigns.
4854
*/
4955
export interface ScopedContextHost {
50-
readonly appAuthored: ScopedContext['appAuthored'];
56+
appAuthored: ScopedContext['appAuthored'];
5157
derive: ScopedContext['derive'];
5258
readonly engine: ScopedContext['engine'];
5359
readonly executionContext: ScopedContext['executionContext'];
@@ -58,6 +64,32 @@ export interface ScopedContextHost {
5864

5965
/** The bodies of `ScopedContext`'s methods: a stateless facet, called by the class's stubs. */
6066
export class ScopedContextFacet {
67+
/**
68+
* A child over `executionContext` that keeps this api's provenance.
69+
*
70+
* The child's private `appAuthored` is written through the child's own host
71+
* (`scopedContextHost.of(child)`), the port its class's static block fills:
72+
* a module function cannot write another instance's private field directly.
73+
*/
74+
static derive(this: Pick<ScopedContextHost, 'appAuthored' | 'engine'>, executionContext: ExecutionContext): ScopedContext {
75+
const child = new ScopedContext(executionContext, this.engine);
76+
scopedContextHost.of(child).appAuthored = this.appAuthored;
77+
return child;
78+
}
79+
80+
/**
81+
* The body of `ScopedContext#[APP_AUTHORED_HOOK_API]`; the method's docblock
82+
* there is its contract. The new api's private `appAuthored` is written
83+
* through its own host, as in {@link ScopedContextFacet.derive}.
84+
*/
85+
static appAuthoredHookApi(this: Pick<ScopedContextHost, 'engine' | 'executionContext'>): ScopedContext {
86+
const ctx = this.executionContext;
87+
const inheritsElevation = ctx.isSystem && !ctx.flowRunId && !appAutomationElevationOf(ctx);
88+
const api = new ScopedContext(inheritsElevation ? markAppAutomationElevation(ctx, 'hook') : ctx, this.engine);
89+
scopedContextHost.of(api).appAuthored = true;
90+
return api;
91+
}
92+
6193
/** The body of `ScopedContext#object`; the method's docblock there is its contract. */
6294
static object(this: Pick<ScopedContextHost, 'appAuthored' | 'engine' | 'executionContext'>, name: string): ObjectRepository {
6395
const repo = new ObjectRepository(name, this.executionContext, this.engine as any);

0 commit comments

Comments
 (0)