Skip to content

fix(objectql): an in-process engine verb refuses an object name the registry does not resolve (#21516) - #21545

Merged
objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/issue-21516-unresolved-name-refusal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/issue-21516-unresolved-name-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21516

Clause-②: yes (narrowing)

An in-process engine verb now refuses an object name the schema registry does
not resolve with the data door's own OBJECT_NOT_FOUND (404), instead of
handing that name to the driver as a raw table name. One name space for the
in-process verbs and the generic data door (triage ruling). The engine's accept
set narrows; no surface is widened. The yes half of the clause line is the one
new export, objectNotFoundError, in @objectstack/core.

What changed

  • packages/core (new objectNotFoundError). One factory for the
    OBJECT_NOT_FOUND / 404 envelope, beside recordNotFoundError and for the
    same ADR-0076 D2 reason (the engine closure cannot import the package where
    the door's envelope was written). Both doors now build the refusal here.
  • packages/metadata-protocol (the door). assertObjectRegistered raises
    that shared factory: same wire status, same code.
  • packages/objectql (the engine). resolveObjectName throws
    objectNotFoundError for a name the registry does not resolve, rather than
    returning it as a physical table name. Every in-process verb
    (find, findOne, count, aggregate, insert, insertMany, update,
    delete, validate) resolves through it, so all refuse uniformly: no
    spelling allow-list, no per-caller marker. judgeFilter keeps judging the
    filter for an unresolved name (it reads nothing and reaches no driver), as its
    contract states.
  • Three platform-internal, constant-name best-effort probes that read a
    known system object and were already fail-soft on a missing table now treat
    the engine's refusal (attributed to their own object) as the same "not
    provisioned in this composition" case. A body cannot reach these paths:
    ObjectQL.probeInstallOrganizations (registry-presence guard),
    SeedLoaderService.resolveSoleOrganizationId and SysMetadataRepository's
    history counters (refusal recognised by code and object).
  • packages/spec. The IObjectQLEngine.judgeFilter docblock states that
    execution refuses an unknown object before admission (comment only; it ships
    in the built type declarations).

Why (classes, doors, roles, codes only)

An action body invoked through the actions door could name a protected member
of the stored-metadata family by a spelling the registry does not resolve and
receive its stored content, whether a member or an administrator invoked it.
The in-process verb handed that name to the driver as a raw table name, and
every name-keyed in-process guard (PR #21513's reader seam among them) was
addressed by the registered name only. The generic data door answers
OBJECT_NOT_FOUND for the same name. The engine now answers the same, so the
two doors share one name space and no name-keyed guard can be stepped around by
naming its target some other way.

Census: does any legitimate platform reader rely on the raw-table fall-through?

Instrumented the resolver's fall-through and ran the objectql,
metadata-protocol and runtime suites, plus a full boot, seed and door drive
of four example apps (crm, showcase, todo, multi-package). The instrument was
reverted in the branch; the net diff carries none of it.

composition fall-through reads reader relies on it?
4 example apps booted, seeded, driven through the doors 0 no: every platform reader addresses a registered object
unit/integration suites (partial registries + tolerant stub drivers) many only test harnesses, plus the three constant-name probes below

Every in-repo caller that passes a possibly-unresolved name, by function:

caller object (constant) pre-change disposition
ObjectQL.probeInstallOrganizations the org object fail-soft on missing table moved: registry-presence, empty answer
SeedLoaderService.resolveSoleOrganizationId the org object fail-soft on missing table moved: recognise the refusal as not-provisioned
SysMetadataRepository history counters the history object fail-soft on missing table moved: recognise the refusal as not-provisioned
ObjectQL.cascadeDeleteRelations / planCascadeAtomicity / referenceExists a relation ref already try/catch unchanged: already tolerate a throw
the data door's existence gate the requested name raised the 404 itself now raises the shared factory

Conclusion: no production or example reader relies on the fall-through.

Merge-queue fix

os migrate account-issuer reads sys_account through the driver the engine routes that name to. Its read-only boot registers no sys_account, and the engine now refuses an unregistered name. The refusal is not read as absence, because that would report a table full of accounts as a clean pre-flight. #21570's missing-table reading for sys_account is kept, and every other failure still throws.

Fixture triage (the test-only fallout, per the seat's answer)

Every test that encoded the raw-table fall-through, by disposition. No ADR text
is edited, and no pin ruled under #7929 (the cross-field withholding decision)
changes what it asserts. The table-keyed captureExpectedReadRefusals noise
pins keep their subject and reshape their reading (item 3).

  1. The unregistered name is the deliberate probe: the test now asserts the
    refusal
    (code + status, and where the test watched the driver, that
    the driver saw nothing).
  2. The fall-through was incidental: the harness now registers what the
    platform reader resolves
    (registered after boot or DDL, so nothing new is
    provisioned and every outage/absence subject keeps its meaning).
    • objectql metadata-write harnesses (delete, save, publish-meta,
      publish-package-drafts, protocol-derived-provenance,
      protocol-save-meta-repo-path, protocol-picklist,
      protocol-publish-canonical-fold): the stored-metadata family.
    • rest (14 harness files): the stored-metadata family, after DDL.
    • plugin-security (4 files) and the http-conformance stack: the authz
      resolver's read set, unprovisioned, so the missing-table answer is still
      what they measure.
    • plugin-approvals status-mirror cascade: the delegation object and the
      org object, unprovisioned. service-settings: the secret and
      setting-audit objects.
  3. Noise pins: same subject, reshaped reading. The org probe for an
    unregistered org object is now refused before any driver, so a pin that read
    "the probe reached the driver and was withheld" now reads "the probe reached
    no driver" (tablesSeen() equal to empty where silentChannels() was
    read). runtime (about 17 files) and trigger-record-change.
    expected-read-refusal-noise.channel-asymmetry.test.ts registers its probe
    object, unprovisioned, so the real driver refusal is still what its two
    channels measure.
  4. cli served-boot control (schema-migrate.host-composition): each
    hook's probe read is witnessed by its recorded OBJECT_NOT_FOUND answer, not
    by a driver line; the SQL driver suppresses that line for its own deferred
    set, so the line never was the subject.
  5. engine.test.ts: one mock parameter typed (name: string), the
    @objectstack/objectql#typecheck red of the earlier heads.

New pin: the measured public door

packages/runtime/src/unresolved-object-name.actions-door.pin.test.ts boots
the plugin set bootStack uses and drives REST /actions. The target is a
table that exists and holds a sentinel row, created out of band at the driver
and registered nowhere (the class the card measured, naming no protected
table). For an administrator and a member, the action body's read answers
404 OBJECT_NOT_FOUND and the sentinel appears nowhere in the answer. Control:
the same body shape on a registered name is served. Reference: the generic data
door's answer for the same name is the same 404. PR #21513's reader-seam pins
stay green.

Ablation (one-shot; nothing left in the tree)

Mutation leg, scripts/ablation-replace.mjs on engine.ts: the refusal in
resolveObjectName replaced by the old raw-table return plus a marker branch
(marker on disk 1, refusal on disk 0); rebuilt; ablation-dist-preflight:
marker present in 4 built files. Pins under mutation:

  • objectql (protocol-unregistered-object, engine-20822-...,
    query-expression-conformance, engine-judge-filter):
    5 failed | 245 passed (250)
  • runtime actions-door pin: 2 failed | 4 passed (6) (both role cases)

Restore leg: git checkout HEAD -- engine.ts; blob equals the HEAD blob
f5793bff919d, git diff HEAD empty, porcelain clean; rebuilt; marker absent
from all 14 built files. Pins restored: 250 passed (250), 6 passed (6).
engine.ts is byte-identical on the final head (the later merge of main
carried no objectql source).

Verification on the final head 6752a29827 (merge of origin/main at 1ca1eb0972)

  • objectql full suite: 367 files, 7384 passed.
  • metadata-protocol full suite: 206 passed, 3 skipped files; 3187 passed, 19 skipped.
  • runtime full suite: 317 files; 5176 passed, 19 skipped.
  • service-analytics full suite: 175 files; 4152 passed, 253 skipped.
  • cli unit tier: 252 files, 3685 passed; integration tier, the three files
    this branch or the merged main touched: 36 passed.
  • mcp: 35 files, 389 passed; dogfood (registry-gate-wiring + the two
    files main added): 16 passed; the main-added example, metadata and
    core files: green.
  • plugin-security, plugin-approvals, service-settings,
    http-conformance, trigger-record-change test tasks: turbo 38/38
    (5 test tasks run, 33 cached builds).
  • Build closure for the above: 63/63 turbo tasks.
  • Before the merge (head bab0903840): typecheck of every touched package
    76/76 tasks; rest repo project 177 passed; the 14 rest harness files
    552 passed, 21 skipped.
  • CI on 6752a29827: every check green except "Part-of PR must not also close
    its card", which read the earlier body; this body is its input.

Acceptance notes

  • Recorded decision of card 3770 ("the engine deliberately does not reject;
    internal callers unaffected") is narrowed at the engine: the door's gate is
    unchanged and the engine now gives the same answer. ADR-0053's type-blind
    lowering is kept for a registered object with no field map, and removed for an
    unregistered name (the bypass itself). No ADR text is edited here.
  • Changesets: @objectstack/core minor (Clause-②: yes, the new export);
    @objectstack/objectql minor (Clause-②: no (narrowing), with its ADR-0087
    disposition); @objectstack/metadata-protocol patch; @objectstack/spec
    patch (docblock).
  • packages/cli/test/refusal-renders-once.e2e.test.ts (added on main) sits
    in neither of the cli package's two vitest projects and was not run here;
    it drives refusal rendering of os init / os compile, which this change
    does not reach.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3

claude added 4 commits October 3, 2026 03:14
…all-through

Records every object name the engine's resolver hands to the driver
without a registry entry, with its caller frames, into the file named by
OS_TEST_UNRESOLVED_CENSUS. Reverted before the refusal lands.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
The census it measured is recorded in the pull request.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
… does not resolve

resolveObjectName no longer hands an unresolved name to the driver as a raw
table name. It throws the data door's own OBJECT_NOT_FOUND 404, built by one
factory in @objectstack/core that the door's object-existence gate now calls
too. judgeFilter keeps judging the filter for such a name (it reads nothing).

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…e engine's refusal as the not-provisioned case

The engine now refuses an unresolved name, so three best-effort platform
probes that read a system table by a constant name no longer reach the
driver when that object is not registered in a lean/bare composition:
ObjectQL.probeInstallOrganizations (sys_organization),
SeedLoaderService.resolveSoleOrganizationId (sys_organization) and
SysMetadataRepository's history counters (sys_metadata_history). Each now
recognises OBJECT_NOT_FOUND attributed to its own object as the same benign
"not provisioned here" case it already recognises for a missing table — a
path a body cannot reach, never the resolver's old raw-table fall-through.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/core, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/error-catalog.mdx (via OBJECT_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in historyCounterVerdict; a string literal in objectNotFoundError; a string literal in resolveSoleOrganizationId))
  • content/docs/automation/flows.mdx (via sys_organization (literal, a string literal in resolveSoleOrganizationId))
  • content/docs/capabilities/permissions.mdx (via OBJECT_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in historyCounterVerdict; a string literal in objectNotFoundError; a string literal in resolveSoleOrganizationId))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/data-modeling/validation-rules.mdx (via sys_account (literal, a string literal in MigrateAccountIssuer; a string literal in SYS_ACCOUNT))
  • content/docs/deployment/cli.mdx (via sys_organization (literal, a string literal in resolveSoleOrganizationId), os migrate account-issuer (command, read off packages/cli/src/commands/migrate/account-issuer.ts))
  • content/docs/deployment/seed-tenancy-repair.mdx (via sys_organization (literal, a string literal in resolveSoleOrganizationId))
  • content/docs/deployment/self-hosting.mdx (via sys_account (literal, a string literal in MigrateAccountIssuer; a string literal in SYS_ACCOUNT))
  • content/docs/kernel/cluster.mdx (via OBJECT_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in historyCounterVerdict; a string literal in objectNotFoundError; a string literal in resolveSoleOrganizationId))
  • content/docs/permissions/administrator-guide.mdx (via sys_organization (literal, a string literal in resolveSoleOrganizationId))
  • content/docs/permissions/authentication.mdx (via sys_account (literal, a string literal in MigrateAccountIssuer; a string literal in SYS_ACCOUNT))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_organization (literal, a string literal in resolveSoleOrganizationId))
  • content/docs/protocol/kernel/error-handling.mdx (via OBJECT_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in historyCounterVerdict; a string literal in objectNotFoundError; a string literal in resolveSoleOrganizationId))
  • content/docs/protocol/objectql/schema.mdx (via sys_organization (literal, a string literal in resolveSoleOrganizationId))
  • content/docs/protocol/objectui/actions.mdx (via sys_account (literal, a string literal in MigrateAccountIssuer; a string literal in SYS_ACCOUNT))

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

  • content/docs/releases/implementation-status.mdx (via sys_organization (literal, a string literal in resolveSoleOrganizationId))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_account (literal, a string literal in MigrateAccountIssuer; a string literal in SYS_ACCOUNT))
  • content/docs/releases/v17/17-1.mdx (via sys_account (literal, a string literal in MigrateAccountIssuer; a string literal in SYS_ACCOUNT), sys_organization (literal, a string literal in resolveSoleOrganizationId))
  • content/docs/releases/v17/17-4.mdx (via sys_organization (literal, a string literal in resolveSoleOrganizationId))
  • content/docs/releases/v17/17-5.mdx (via IObjectQLEngine (symbol, a top-level interface), judgeFilter (symbol, a method of class ObjectQL), OBJECT_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in historyCounterVerdict; a string literal in objectNotFoundError; a string literal in resolveSoleOrganizationId), sys_account (literal, a string literal in MigrateAccountIssuer; a string literal in SYS_ACCOUNT), sys_organization (literal, a string literal in resolveSoleOrganizationId), os migrate account-issuer (command, read off packages/cli/src/commands/migrate/account-issuer.ts))

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 changed file(s) yielded no anchor (packages/core/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 71 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 149 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 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c84a16078041482977cd8ac13a1e738ec366f917 — the merge of head 2df18ea792202e43ae85a0d3a7d4a5ff683af9b7 into base 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d, 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 c84a16078041482977cd8ac13a1e738ec366f917 && git checkout c84a16078041482977cd8ac13a1e738ec366f917
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d 2df18ea792202e43ae85a0d3a7d4a5ff683af9b7 && git checkout -B drift-repro 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d && git merge --no-ff 2df18ea792202e43ae85a0d3a7d4a5ff683af9b7

node scripts/docs-audit/affected-docs.mjs --json 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d

⚠️ 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 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 3 commits October 3, 2026 05:08
… family they write through

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
Deliberate probes of an unregistered name now assert the refusal (the data
door's OBJECT_NOT_FOUND envelope, nothing reaching the driver); harnesses where
the fall-through was incidental register the objects they write through. The
#3770 case-B pin keeps the door's 404 and flips its engine assertion.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
claude added 7 commits October 3, 2026 05:44
…s refusal

An unregistered organization object (and a view's unregistered probe
object) is no longer read through the driver: the engine refuses the name
first, so the declared refusal no longer occurs. The capture stays declared
and each pin now asserts nothing was withheld, so a returning read turns it
red. The channel-asymmetry pin registers its probe object (unprovisioned) so
it still measures a real driver refusal.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
… readers resolve

The engine now refuses an object name its registry does not hold, so partial
compositions register what a deployment's plugins register: the authz
resolver's read set (left unprovisioned, so it still reads "no grants"), the
settings service's secret and audit objects, and the approvals fixture's two
expected-absent probes (unprovisioned, so its withheld-refusal pin is
unchanged).

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…l reads

The engine now refuses an object name its registry does not hold, so the
real-engine import/export and classification harnesses register the family
after their DDL; an unprovisioned store still answers the driver's own
"no such table", which those pins classify.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…r answer

The control's driver lines existed only because the hooks read objects the
boot never declared through the engine's raw-table fall-through; the engine
now refuses those names before any driver. Each probe read now records its
answer, and the control asserts OBJECT_NOT_FOUND and no driver line.

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
… refusal; type a mock

The record-change org-probe pin asserts the absent organization object is
quiet by construction; the conformance stack registers the authz resolver's
read set (unprovisioned) that its stubbed auth service never did; the
engine.test expand mock types its parameter (test-typecheck ledger).

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
… not resolve; changesets

An action body through REST /actions reading an out-of-band table by its
unregistered name now answers 404 OBJECT_NOT_FOUND for an administrator and a
member, with nothing of the table in the answer; the same body on a registered
name is served (the control); the data door's own 404 is the reference.

Changesets: core minor (new objectNotFoundError export), objectql minor
BREAKING narrowing with its ADR-0087 disposition, metadata-protocol patch,
spec patch (contract docblock).

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tooling and removed size/l labels Oct 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

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

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

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

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL   integration  src/commands/migrate/data-commands.absent-database.integration.test.ts > [#21552] the rest of the family: a project with no database yet is empty work, exit
      ↳ 失败原因: @objectstack/cli:test: AssertionError: 2026-10-03T12:46:35.209Z INFO Registered metadata loader: filesystem (file:)
    @objectstack/cli:test:  FAIL   integration  src/commands/migrate/data-commands.absent-database.integration.test.ts > [#21552] the rest of the family: a project with no database yet is empty work, exit
      ↳ 失败原因: @objectstack/cli:test: AssertionError: expected 1 to be +0 // Object.is equality
    @objectstack/cli:test:  FAIL   integration  src/commands/migrate/data-commands.absent-database.integration.test.ts > [#21552] the rest of the family: a project with no database yet is empty work, exit
      ↳ 失败原因: @objectstack/cli:test: AssertionError: 2026-10-03T12:46:47.032Z INFO Registered metadata loader: filesystem (file:)
    

↳ 失败原因 是判读的关键:超时(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,按失败测试文件聚合):

  • src/commands/migrate/data-commands.absent-database.integration.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

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

分诊清单:

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

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

claude added 2 commits October 3, 2026 13:20
…ver, not the engine

The read-only boot composes no auth plugin, so sys_account is not a
registered object there, and the engine now refuses a name its registry does
not resolve before any driver is asked. The pre-flight inventories the
physical table in its legacy shape, so it reads through the driver the engine
routes that name to; the missing-table refusal of that read is still the only
one recognised as no rows.

Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn
Co-authored-by: Claude <noreply@anthropic.com>
…g driver is the probe's refusal

Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT of the merge-queue fix at head 2df18ea792 (the contract review PASS 5969221098 stands on 6752a29827); auto-merge is re-armed in this act · 2026-10-03T14:23Z

Director seat, summon #32, session_016tKoy8NJa35Yih1FdzrVmn, claim 5969459264 on #21516. Reviewed against the pushed commits, not the report (5970043342).


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit eb9ef79 Oct 3, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21516-unresolved-name-refusal branch October 3, 2026 14:57
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…is not offered; the admin read says why (objectstack-ai#21580)

Part of objectstack-ai#21476
Clause-②: no

This delivers the doors half and the administrator's read half of the
triage ruling (`5962758813`). The publish half is left open on purpose:
its contract-faithful channel sits behind
`packages/metadata-protocol/src/protocol.ts`, which open PRs hold. The
call sites are under "Not in this PR". The keyword is `Part of`, so the
card stays open after this merges, while that half waits for a decision.

## What

On a walled tenancy posture, a public form bound to an object walled by
an organization column is no longer offered to anonymous visitors.

An anonymous submission carries no organization. On a walled posture the
engine refuses an insert without one into such an object
(`resolveSystemInsertOrganization`). So the form was served (`GET
/forms/contact-us` 200), and then every submit answered `500
ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. This was reproduced on
`origin/main` (`6dd99b82c3`) with `bootStack(showcaseStack, {
multiTenant: 'posture-only' })`.

- **One predicate.** `anonymousFormIntakeUnavailability` in
`packages/rest/src/rest-server.ts` returns null when the form can take
intake, otherwise why it cannot. It reads two facts and restates
neither:
- the tenancy service's in-force `posture`. This is the value
SecurityPlugin hands the engine (`setTenancyPostureProvider`), so a
degraded walled request reads `single` there, and the engine derives the
install's organization.
- the wall column, from `@objectstack/metadata-core`'s existing
`resolveRecordWallOrganizationField`, read over the served object schema
(which carries the injected `organization_id`).
The object is read only once a wall is in force, so single-posture
deployments pay nothing new.
- **Both doors read it in one place.** It is called inside
`resolveFormBySlug`, the one resolution both `GET /forms/:slug` and
`POST /forms/:slug/submit` already call. An unavailable form resolves to
`null`, which is exactly the withdrawn form's `404 FORM_NOT_FOUND`, byte
for byte. Anonymous callers learn nothing about the tenancy, and there
is no second check per door.
- **The administrator's read names why.** `GET /meta/view/:name` puts
one warning in `item._diagnostics.warnings` per unavailable open form.
It sits on both arms (cached and uncached), is located at the form's
`sharing` (`config.sharing`, `formViews.KEY.sharing` or `form.sharing`),
and names the slug, the object, the wall column, the posture and the
remedy (`tenancy: { enabled: false }` when the rows belong to no
organization). The reason depends on facts the protocol's validator
never hashes, so on the cached arm a `view`'s If-None-Match is compared
in REST against an ETag with the reason folded in (the ADR-0106 D3
shape). With no reason, the ETag and the 304 are byte-identical to
before (pinned).

### Placement: the predicate lives in `rest`, not `metadata-core`

The dispatch assumed `metadata-core`'s `anonymous-form-intake.ts`. I
measured that first: any new export there enlarges
`@objectstack/metadata-core`'s published index (`export *`), which is a
`Clause-②: yes (widening)` change by objectstack-ai#21566's own grading. The dispatch
pins `Clause-②: no`.

Every reader of the predicate (two doors, one admin read) is in
`rest-server.ts`. The predicate composes two rules that are already
shared (`postureEnforcesWall` from spec,
`resolveRecordWallOrganizationField` from metadata-core), so no rule
gains a second spelling. It moves into `metadata-core` on the day a
reader outside `rest` exists, for example the publish half's option A
below.

## Pins

- `packages/rest/src/public-form-intake-availability.test.ts` (16
tests):
- The doors are enumerated off the registered routes. The set under
`/forms/` must be exactly the two doors, and each door on each walled
posture (`isolated`, `group`) is its own row: it answers the withdrawn
form's answer byte for byte, and `createData` is never called.
- Controls, all accepted: a `tenancy: { enabled: false }` object, the
single posture (where the object is not even read), a degraded walled
request, and no tenancy service.
- Admin read on both arms: the located warning appears on the walled
cases, and `_diagnostics` is untouched on the controls.
- Validator: the bare protocol ETag revalidates into the reason, the
folded ETag gives 304, and the control's ETag is unchanged and still
gives 304.
-
`packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts`
(real walled showcase boot):
- both doors' raw answers equal the same form's answers once withdrawn
env-wide on the same boot, and no `showcase_inquiry` row lands;
  - the admin read names the reason at `config.sharing`.
- `public-form-withdrawal-walled.dogfood.test.ts`: the dogfood control.
A tenancy-disabled object on the walled boot still accepts intake, and
its admin read now also asserts that no intake warning is present.

## Ablation (one-shot, not kept)

Each mutation was applied with `scripts/ablation-replace.mjs` against
`packages/rest/src/rest-server.ts`, whose HEAD blob is `239ac2d6` at
both `8a8839f9ab` and the final `66ee5294a6`. The direction was
predicted before each run.

- **A, unit leg: the doors stop reading the predicate.** `return
unavailable ? null : { ...match, organizationId };` became `return {
...match, organizationId };` (anchor 1 to 0, blob `239ac2d6` to
`43fdf709`).
  - Predicted: exactly the 4 door rows red, everything else green.
- Observed: 4 failed, 12 passed. The GET rows received 200 and the POST
rows 201 where 404 was expected; the admin-read rows stayed green
because they reach the predicate from their own call site.
  - Restore: blob equals HEAD and `git diff HEAD` is empty.
- **A, dist leg (dogfood).**
- The first attempt was a no-op. The same replacement left `unavailable`
unused, and the DTS build refused it (`noUnusedLocals`) after the JS
bundle had already been emitted, so no test ran. I rebuilt from the
restored source, and `ablation-dist-preflight` confirmed the guard
present in `dist/index.js` and `dist/index.cjs` with a clean tree.
- Re-run with `return unavailable && false ? null : { ...match,
organizationId };` (blob `2b2e9c9f`), rebuilt; the preflight found the
plant marker in 2 built files.
- Predicted: 1 red. Observed: 1 failed (`expected 200 to be 404` on the
doors row), 8 passed.
- Restore: blob equals HEAD, rebuilt; the preflight found the guard in 2
files, the mutation absent from all 6, and a clean tree; the dogfood run
went back to 9/9.
- **B: the predicate itself answers "available".** `return tenantField
=== null ? null : ...` became `return tenantField === null || true ?
null : ...` (blob `25ef3c8e`).
- Predicted: 7 red (4 door rows, plus the 3 walled admin-read rows:
uncached, cached, validator) and 9 green.
- Observed: 7 failed, 9 passed. Restore: blob equals HEAD and the tree
is clean.

The pins asked for an ablation "on one door". There is no per-door read
site to ablate: the predicate is read once, in the resolution both doors
call. Ablation A removes that one read, and each door's own row goes
red.

## Tests (at `66ee5294a6`, after merging `origin/main` `44072fc2b9`)

- `@objectstack/rest` full suite (`--project local`): 258 files passed;
4883 tests passed, 326 skipped. `typecheck`: `tsc --noEmit` clean, and
`check:test-typecheck` OK.
- `@objectstack/metadata-core`: 17 files / 311 passed; `typecheck` clean
(it is unchanged).
- `@objectstack/dogfood` `typecheck` clean. Public-form dogfood files
(walled intake, walled withdrawal, showcase withdrawal, showcase public
form, read-back masking): 5 files / 20 passed.
- `@objectstack/runtime` `/meta` parity census, run because it reads
`rest-server.ts` source and `rest`'s `dist/`. The four files
`meta-list-projection-parity`, `meta-item-read-gate-parity`,
`meta-read-org-scope-parity` and `meta-item-envelope` gave 780 passed.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 68 commands; 67 exit 0. `check:dual-build-cjs-loads`
answered PREREQUISITE NOT MET (it needs a full workspace build), so it
is NOT MEASURED and left to CI. The `--ran` reconciliation accounted for
68 of 68: 67 run, 1 NOT MEASURED, 0 unrun.
- eslint, narrowed to the 4 changed `.ts` files (`--no-inline-config
--format json`): 4 files, 0 errors, 0 warnings, none ignored. The fifth
changed path is a changeset `.md`. The narrowing is sound because
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move any untouched file's
verdict. The full `pnpm lint` is CI's.

## Not in this PR: the publish half

The ruling asks the publish surfaces (`PUT /meta/view/:name`, `POST
/meta/view/:name/publish`) to say why too. The only located, structured
channel those responses carry is `advisories`, and both
`SaveMetaItemResponseSchema` and `PublishMetaItemResponseSchema` declare
the runtime authoring gate as its producer. The places that would have
to change:

- `packages/metadata-protocol/src/runtime-authoring-gate.ts`: a
gate-local rule would sit beside `findPlatformScheduleOrgGaps`, around
line 300.
- `packages/metadata-protocol/src/protocol.ts:5612`: the gate is fed
`orgWallEnforced: this.orgWallEnforced()`.
- `protocol.ts:5938`: `orgWallEnforced()` reads the requested posture
(`postureEnforcesWall(resolveTenancyPosture())`), which disagrees with
the doors' in-force reading on a degraded deployment.
- `protocol.ts:18673` and `protocol.ts:19612`: the attach sites.

`protocol.ts` is held by open PRs objectstack-ai#21545 and objectstack-ai#21512, so this PR stops
there. The options and a recommendation are in the report on the card.

## Acceptance notes

- **Boundary of the predicate.** It reads declarations. The engine also
passes a federated (`external`) object, a platform object its inventory
has not admitted, and a row a `beforeInsert` hook stamped. A form bound
to one of those that also carries a wall column is withheld here
although the engine would accept it, which is the fail-closed direction.
No shipped hook stamps `organization_id` (`git grep` over the CRM and
showcase hooks: exit 1, with a `beforeInsert` control at exit 0).
- **New failure mode on walled postures.** An object-metadata read
failure now fails both doors closed (GET `500 FORM_RESOLVE_FAILED`;
submit through `mapDataError`). Single-posture deployments make no new
read.
- **Cached arm.** For every `view` read, If-None-Match is now compared
in REST rather than in the protocol. Server work is unchanged, because
`getMetaItemCached` already delegates to `getMetaItem`. Response bytes,
the ETag and the 304 are identical when there is no reason.
- **Not stamped by this PR.** The list read (`GET /meta/view`),
`/layers`, and the runtime HTTP dispatcher's `/meta` item read. The
dispatcher serves no `/forms/*` door, so a dispatcher-only composition
has no intake that could be unavailable.
- **CRM.** `app-crm`'s lead form (`/forms/contact-us`) is the same class
on a walled CRM deployment. Not booted here.
- **Console.** Whether the console renders `_diagnostics.warnings`: NOT
MEASURED (no `objectui` checkout in this container).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… driver's own expression, so SQLite runs it (objectstack-ai#21587)

Fixes objectstack-ai#21441

Clause-②: yes (widening)

## What changes

The ObjectQL face's echoed `sql` and the `POST /api/v1/analytics/sql`
body now print a date-bucketed dimension in the bucket expression the
driver itself groups by for its dialect. Before, `generateSql` printed
`date_trunc('GRANULARITY', col)` on every dialect. SQLite refuses that,
and PostgreSQL answers timestamps where the face answers `2026-01`.

The route is the one the seat answered for Q1 (A), with Q2 = A from
triage: the mechanism governs on every dialect.

- **`driver-sql`**: one public member,
`SqlDriver.dateBucketSql(objectName, field, granularity)`. It returns
`knex.raw(sql, bindings).toQuery()` over the unchanged
`buildDateBucketExpr(field, granularity, objectName)`, or `null` where
that returns `null`. No change to what `buildDateBucketExpr` returns,
and no spec member.
- **`service-analytics`**:
- `strategies/types.ts`: one optional context member, `dateBucketSql`,
beside `sqlDialect`.
- `analytics-service.ts`: one optional
`AnalyticsServiceConfig.dateBucketSql` and one `baseCtx` pass-through
line.
- `plugin.ts`: wires the hook from `getDriverForObject`, as `sqlDialect`
is wired (structural read, `typeof` guard, `undefined` on every tier
that cannot answer).
- `objectql-strategy.ts`: `dimExpr` prints the hook's answer, and keeps
`date_trunc` where nothing answers. The "REPRESENTATIVE" docstring
sentence is narrowed to exactly those cases. The false comment ("the SQL
shape the driver's own bucketing implements") is corrected.
- **`driver-turso`**: the comment that said `SqlDriver` emits
`date_trunc` is corrected (comment only). `REMOTE_FACE_ANSWERS` gains
one row, `dateBucketSql: 'inherited'`. Its `satisfies` pin over every
key of `SqlDriver` fails the package's build until every public
`SqlDriver` member is classified, so the ruled driver member forces this
row. The row is not on the package's public surface: it is not exported
from the index, and tsup drops it from `dist/`.

There is no second bucketing table and no dialect branch in
`service-analytics`.

## Where the echo keeps `date_trunc`

The hook answers nothing, and the bucket stays representative, in four
cases:

1. No hook is wired.
2. The driver has no bucket expression (a non-SQL driver).
3. The granularity is one the driver buckets in memory (`week` on
SQLite: `buildDateBucketExpr` returns `null`).
4. The query has a non-UTC `timezone`.

Case 4 is the strategy's own gate (`zone && zone !== 'UTC'`). It mirrors
objectql's `tzRequiresInMemory` (ADR-0053 Phase 2, D2). A non-UTC zone
makes the engine bucket in memory on that zone's calendar, which the
driver's UTC expression does not describe. Measured: with `timezone:
'Asia/Shanghai'` the face answers `2026-01: 20, 2026-02: 8`, while the
driver's UTC expression would answer `27, 1`.

**Not gated: a measure `filter`.** The engine also buckets in memory
when a measure carries a `filter` (`hasAggregationFilter`). It does so
on the same UTC calendar the driver's expression is held to ("Must match
`bucketDateValue()` exactly"), so there the driver expression answers
the face's keys, and the echo uses it. Measured on both engines (pinned
below).

## Measured

**Premise, at `main` `0bddffd55b`.** Measured through the real
`createDispatcherPlugin` mount (`POST /api/v1/analytics/query` and
`/sql`), default composition, with `SqlDriver` on better-sqlite3 and on
a live PostgreSQL 16.14 (private cluster, stopped and removed
afterwards). Each query ran 0 raw statements and 1 engine aggregate, so
these are ObjectQL-face answers.

| cell | the driver ran | echo (= `/sql` body) | the echo, run |
|:--|:--|:--|:--|
| SQLite month / quarter (date and datetime column) | `strftime('%Y-%m',
...)` / `(strftime('%Y', ...) \|\| '-Q' \|\| ...)` | `date_trunc('month'
/ 'quarter', col)` | `no such function: date_trunc` |
| SQLite week | `select *` (in-memory bucketing) | `date_trunc('week',
col)` | `no such function: date_trunc` |
| PG month / quarter / week | `to_char((col)::timestamptz AT TIME ZONE
'UTC', 'YYYY-MM' / 'YYYY"-Q"Q' / 'IYYY"-W"IW')` | `date_trunc(...)` |
runs; keys `2026-01-01T00:00:00.000Z` where the face answers `2026-01` |
| any engine, `timezone: 'Asia/Shanghai'` | `select *` (in-memory
bucketing) | `date_trunc('month', col)` | SQLite refuses; PG answers UTC
buckets |

**After, at `a61c6d79a9`** (same harness, same mount):

- SQLite month and quarter echo `strftime(...)` on both column types,
and run with the face's row count.
- PG month, quarter and week echo the driver's `to_char(...)` and run.
- SQLite week, and the non-UTC zone on both engines, keep `date_trunc`.
- The measure-filter dataset echoes the driver expression.

The harness was a scratch copy of
`runtime/src/analytics-query-window-validity.test.ts`, deleted after the
run.

**The MySQL arm is by code read only.** No MySQL server was available.
The member renders the driver's own `date_format(convert_tz(??,
@@session.time_zone, '+00:00'), ...)` arm through the same `toQuery`.

**Turso remote face, measured with a scratch harness (deleted).** A
`TursoDriver` in remote mode over a libSQL `file:` client answers
`dateBucketSql` byte-identically to the local face, with no connection.
libSQL runs it (`2026-01`, `2026-Q1`). Week answers `null` on both
faces. Remote mode advertises an empty `queryDateGranularity`, so the
engine buckets there in memory, on the UTC keys this expression answers.

## Pins


`packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts`,
default plugin composition. SQLite runs every time; live PostgreSQL runs
behind `OS_TEST_POSTGRES_URL`, which no CI step sets for this package (a
named skip there). Run at the final head `5032b8f3e9` with live PG
16.14: **16 passed (16)**, 8 of them live PG.

- **Month and quarter** (SQLite), and **month, quarter and week** (PG),
on a `date` and a `datetime` column:
  - the echo equals `generateSql` (the `/sql` body), with no params;
- it selects and groups by an expression that is not `date_trunc` and
that the driver's own aggregate statement contains;
  - run through the engine's raw-SQL bridge, it answers the face's rows.
- **A measure `filter`** (the engine aggregates in memory): the echo
carries the driver expression, and run with its params it answers the
face's rows.
- **FALLBACK, week on SQLite**: the driver grouped nothing, and the echo
keeps `date_trunc('week', closed_on)`.
- **FALLBACK, non-UTC `timezone`** (both engines): the face answers the
zone's calendar, and the echo keeps `date_trunc('month', closed_at)`.
- **FALLBACK, no hook**: an `ObjectQLStrategy` whose context names no
hook echoes `date_trunc('month', closed_on)`.

The dispatch's pin "on SQLite, week bucketed echoes run" does not hold
under the ruled fallback. The SQLite driver buckets week in memory
(`dateGranularityCapabilities.week` is false), so the hook answers
nothing there and the echo keeps `date_trunc`, which SQLite refuses. It
is pinned as a fallback instead.

## Ablations

Each was predicted first, run through `scripts/ablation-replace.mjs` in
wrap mode, and its restore was proven by the tool: blob equals the HEAD
blob, and `git diff HEAD` is empty. The subject is `src`, imported
relatively by the pin, so no `dist` leg applies.

- **A. Hook wiring removed** (the plugin's `dateBucketSql,` config line
replaced by a comment).
  - Predicted: red, 12 failed / 4 passed.
- Observed: red, **12 failed / 4 passed**, every failure `expected
'date_trunc(...)' not to contain 'date_trunc'`. The 4 fallback pins
stayed green.
  - Restore: blob `e1378f313e49` equals HEAD's.
- **B. The non-UTC gate removed.**
  - Predicted: red, 2 failed / 14 passed.
- Observed: red, **2 failed / 14 passed**: the two non-UTC pins, which
got `strftime(...)` and `to_char(...)` where they expect `date_trunc`.
  - Restore: blob `3769d2062c91` equals HEAD's.

## Verification

At final head `5032b8f3e9` unless noted:

- `pnpm --filter @objectstack/service-analytics test` (no PG, as CI runs
it): 176 files, **4160 passed**, 261 skipped, exit 0 (at `1e3cc1fc72`;
the only later change is the changeset file).
- The same suite with live PG 16.14 set: 4418 passed, **1 failed**. The
failure is `read-scope-temporal-coercion.test.ts`'s premise check, "the
server is not on UTC": this private server ran `Etc/UTC`. That is
environmental, not this change.
- `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. `tsc
--listFiles` reaches the new test file.
- `pnpm --filter @objectstack/driver-sql test`: 216 files, **3611
passed**, 204 skipped, exit 0. `driver-sql typecheck`: exit 0.
- `pnpm --filter @objectstack/driver-turso test`: 88 files, **2366
passed**, 33 skipped, exit 0. `driver-turso typecheck`: exit 0.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derives 67 commands. All 67 ran
at `5032b8f3e9` and exited 0. `--ran` reconciliation: "67 derived, 67
run, 0 NOT-MEASURED, 0 UNRUN". `check:dual-build-cjs-loads` first
answered `PREREQUISITE NOT MET` (no `dist`) and was re-run after a root
build.
- Lint, a declared narrowing:
1. **Population**, read from eslint's own config: of the 8 changed
paths, the 7 `.ts` files are linted, and the changeset `.md` is ignored
("no matching configuration").
2. **Count**, from `--format json`: 7 files linted, 0 errors, 0
warnings, at `5032b8f3e9`.
3. **Invariance**: `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`), so this diff cannot move an untouched
file's verdict.

  The full `pnpm lint` is CI's.

## Acceptance notes

- **Comment drift left in place (excluded):** the `objectql` `engine.ts`
comment above `tzRequiresInMemory` still calls native driver bucketing
`date_trunc`. Open PR objectstack-ai#21545 holds that file. Carrier: whoever next
edits that comment.
- **Comment drift in a governed surface (noted, not filed):**
`skills/objectstack-ui/rules/dashboards.md` ("Engine support") says
Postgres buckets with `date_trunc`. `SqlDriver` emits `to_char(... AT
TIME ZONE 'UTC', ...)`. `skills/**` is Tier H, so it is not touched
here. Carrier: none.
- **SQLite week stays unrunnable:** `date_trunc('week', ...)`, by the
ruled fallback. Closing it needs the driver to bucket week in SQL on
SQLite (a `driver-sql` capability decision), which this card does not
make.
- objectstack-ai#21485 (the PG and MySQL `date`-column shift under a non-UTC server)
is not addressed here. Because the echo now renders the driver's
expression, its fix reaches the echo with no second edit.
- Merged `origin/main` once (`cc645f2385`, docs-only, disjoint). A later
`main` commit (`a7ab047cf6`, `packages/rest` only) is not merged; the
queue rebuilds on it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…er's tenant marker, so an unscoped kernel answers an expanded view as env_local does (objectstack-ai#21511) (objectstack-ai#21603)

Fixes objectstack-ai#21511
Clause-②: no

## What this changes

On an unscoped (control-plane) kernel, registry hydration registers
every view a stored environment-wide view container expands, each under
its own name. `hydrateOverlayIntoRegistry` registers the container with
the tenant-authorship marker (`stateTenantAuthorship`, ADR-0010
`_provenance: 'org'`), and `hydrateExpandedViewItems` registered the
expansions without it. An expansion of a package-bound container
therefore sat under its bare name, wearing that package's `_packageId`
and no marker, and `SchemaRegistry.getArtifactItem`'s bare-key fallback
took it for a view the package ships.

This PR implements triage's ruling (comment 5964348889): each expansion
inherits its container's authorship.

- `hydrateExpandedViewItems` asks `expandRuntimeViewContainer` for
tenant-authored expansions (`tenantAuthored: true`).
- Under that option, `expandRuntimeViewContainer` applies
`stateTenantAuthorship` to each expansion BEFORE that expansion's own
artifact envelope is merged over it. This is the order the container
gets (`mergeArtifactProtection(stateTenantAuthorship(data), envelope)`),
so where the container's own package ships a view of that name, the
artifact's `_provenance`, `_packageId` and `_lock` still win (ADR-0010
§3.3).
- The two registry-free reads that call `expandRuntimeViewContainer`
(the list read's expansion pass and the by-name read's step 1b, through
`expandStoredViewContainers`) pass no such option and serve exactly what
they served before.
- No reader changed. `isArtifactBacked` (which `resettable` reads) and
the layered read's `code` arm already ask `isTenantAuthored`; they now
get the marker to read.

## Measured before the change

At base `a7ab047c`, with the objectstack-ai#21508 harness (`showcaseHarness`) and the
card's probe (a container `os_qa_probe` on `showcase_task` with
`listViews.in_progress`, read as
`showcase_task.os_qa_probe.in_progress`):

| kernel | container | `getMetaItem(...).resettable` |
`getMetaItemLayered(...).code` |
|---|---|---|---|
| `env_local` | package-bound (`com.example.repairassets`) | `false` |
`null` |
| `env_local` | package-less, environment-wide | `false` | `null` |
| unscoped | package-bound | **`true`** | **the hydrated expansion** |
| unscoped | package-less, environment-wide | `false` | **the hydrated
expansion** |

At the base the registry held the container as `{ name: 'os_qa_probe',
_provenance: 'org' }` and the expansion as `{ name:
'showcase_task.os_qa_probe.in_progress', _packageId:
'com.example.repairassets' }`, with no `_provenance`. For the
package-less arm the expansion carried no `_packageId`, so `resettable`
was already `false`. Its `code` layer was still wrong, through the
layered read's runtime-only `getItem` arm, which drops only
tenant-marked entries.

After the change, both kernels give `env_local`'s answer for every
member kind in both arms.

## The save door (ruling: no save-door rule change)

The fix changes what `isArtifactBacked` answers for an expanded name on
the unscoped kernel, and the save door reads that predicate. The door's
acceptance is pinned rather than assumed. After the container is saved,
a write by the expanded name is accepted on both kernels and in both
arms, stored once in the container's scope, and that row then answers
the name on the by-name read and on the object door. The outcome is
identical across the two kernels. The same probe was accepted on all
four kernel and arm combinations at the base. Under reverse verification
leg 1 below, the save-door pins stay green, so the acceptance does not
move with the fix.

## Tests


`packages/metadata-protocol/src/view-container-runtime-expansion.test.ts`
gets a new describe block, `objectstack-ai#21511 an expanded view of a stored
container answers as tenant-authored on both kernels`, with 15 tests in
objectstack-ai#21508's harness. It covers the package-bound and package-less
environment-wide arms. Hydration never registers an organization-scoped
row.

- For each arm and each of the five member kinds: the expanded view is
not resettable and has no `code` layer on either kernel, and the
unscoped kernel's whole answer equals `env_local`'s (`resettable`,
`editable`, `deletable`, `lock`, `provenance`, `packageId`, `code`).
That is 10 tests.
- For each arm: on the unscoped kernel, every registered expansion
carries the container's marker, keeps the container's package, and is
not a code artifact (`isCodeArtifactBody`).
- For each arm: the save-door pin described above.
- CONTROL: a package-less overlay of the showcase's own `showcase_task`
container. Its expansions, `showcase_task.default` and
`showcase_task.in_progress`, stay resettable with the packaged `code`
layer on both kernels, and on the unscoped kernel the registered
expansion keeps the artifact's envelope (`_provenance: 'package'`,
`_packageId: com.example.showcase`) over the marker.

## Reverse verification (both runs recorded)

Each run starts from the committed fix, and each leg restores with `git
checkout HEAD -- ABSOLUTE_PATH`. Each restore is proven by blob hash
equal to the HEAD blob, an empty `git diff HEAD`, and a clean `git
status`, all inside a script armed with `trap restore EXIT INT TERM`.
The subject is imported from source (`./index.js`), so no `dist/` is
involved.

**Run 1, at `e8e00609`** (the fix commit, before merging main):
- Leg 1: `protocol.ts` was reverted to the base blob `3ac2573f` (`git
restore --source=a7ab047c`). The landing was proven by the on-disk blob
equalling the base blob; the base blob carries 0 occurrences of
`tenantAuthored: true`, and HEAD carries 1. Result: **12 failed, 132
passed (144)**. The 10 `resettable`/`code` pins failed with `unscoped:
no package ships it: expected true to be false` (package-bound) and
`unscoped: no artifact, so no code layer: expected {…} to be null`
(package-less). The 2 marker pins failed with `expected undefined to be
'org'`. The 2 save-door pins and the CONTROL stayed green.
- Leg 2: the marker was applied after the envelope instead of before,
through `scripts/ablation-replace.mjs`, with anchor 1→0, replacement
0→1, and blob `b106f11e` → `5d6e6263`. Result: **1 failed, 143 passed**.
Only the CONTROL failed: `showcase_task.default: the artifact's envelope
is merged over the marker, not under it: expected { _provenance: 'org' }
… { _provenance: 'package' }`.
- Restored HEAD: **144 passed**.

**Run 2, at `09033d87`** (after merging `origin/main` `6c5697df`, which
includes the landed objectstack-ai#21545 as `eb9ef791`):
- Leg 1 reverted to the merged base's blob `24cd0629` (`6c5697df`): **12
failed, 132 passed**, with the same 12 tests and the same messages.
- Leg 2: **1 failed, 143 passed**, the CONTROL alone.
- HEAD: **144 passed**.

## Verification at `09033d87`

- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2`: Test Files 207 passed, 3 skipped (210); Tests 3207
passed, 19 skipped (3226); `VERDICT command-exit 0`.
- `pnpm --filter @objectstack/metadata-protocol typecheck` (`tsc
--noEmit`) exited 0. Its `--listFiles` reaches 210 of the package's 210
test files, including the edited test file.
- Lint, as a proven narrowing: `eslint --no-inline-config --format json`
over the two edited `.ts` files gives 2 files, 0 errors and 0 warnings.
The changeset `.md` is outside every `files` glob of
`eslint.config.mjs`. `--print-config` shows no `parserOptions.project`
or `projectService` (type-aware linting is not enabled), so this diff
cannot move the verdict on any untouched file. The full `pnpm lint` is
CI's.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths)
derived 64 families for this change set. 63 exited 0. `pnpm
check:dual-build-cjs-loads` is **NOT MEASURED**: it exited 3
(PREREQUISITE NOT MET), because it needs every package's `dist/` and 67
had none. This diff changes no exports, entry points or build config.
`--ran` reconciliation: 64 accounted, 63 run, 1 NOT MEASURED. Two first
runs were prerequisite misses and were re-run green after the
prerequisite was met. `check-plugin-teardown-shape --self-test` needed
its pinned fixture commit fetched into the shallow clone.
`check:lean-entry-closure` needed `@objectstack/objectql` built.

## Region and surface

`protocol.ts` hunks: the `stateTenantAuthorship` docblock (its "ONE
caller" sentence now names both callers), `expandRuntimeViewContainer`'s
options type and its merge line, and `hydrateExpandedViewItems`' call
and docblock. The claim names the `hydrateExpandedViewItems` region.
`expandRuntimeViewContainer` sits in the same hydration block, and the
stamp must go there so that it precedes each expansion's own envelope
(the order above), without a second copy of the envelope rule. That is
the one widening of the region, declared here. The save door and the
data door's read region are not edited. objectstack-ai#21545's hunks (landed as
`eb9ef791`, merged here) are disjoint from these.

## Acceptance notes

- **Card premise, refined:** for the package-less environment-wide arm,
only the `code` layer was wrong at the base; `resettable` was already
`false` (table above). Both values are pinned now.
- **Save-door intent on the unscoped kernel:** for a write by an
expanded name of a package-bound container, `isArtifactBacked` now
answers `false`, as on `env_local`. Reading the save path (not
separately measured), the write intent it derives is therefore the
runtime-only one rather than the artifact-override one. The ruling
expects this ("layered by the corrected predicate on both kernels").
Acceptance is unchanged, as measured above.
- **Not measured over REST.** The reads are pinned at the protocol
methods the by-name and `/layers` REST routes call.
- The branch was merged with `origin/main` at `6c5697df`. One later main
commit (`f6b75208`, spec conformance-case notes and a lint test) is not
merged. It touches none of this PR's files.

Changeset: `.changeset/21511-expansion-tenant-marker.md`, `patch` for
`@objectstack/metadata-protocol`.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… an orphaned column (objectstack-ai#21612)

Fixes objectstack-ai#21571

Clause-②: no (narrowing)

An unprojected read now serves the object's declared fields plus the
platform's system columns. A column no metadata declares, such as a
field retired in an upgrade whose column additive sync leaves behind, no
longer leaves the engine. The decision is made once, in the engine
(`packages/objectql`), so every driver and every door gets the same
answer. No driver source and no `metadata-protocol` source is edited.

## Measured first (before any fix)

The probe was a temporary, env-gated recorder at the engine's read sites
(commit 920cecf, reverted by a7500b3; the net diff carries none of
it). In trim mode it deleted every undeclared key at `find`, `findOne`,
the by-id update result and the update/delete prior reads, a superset of
this fix.

| suite (tree) | files | under trim | undeclared-key hits |
| --- | --- | --- | --- |
| objectql (a7ab047) | 366 | 8 failed, 7374 passed | 50 |
| rest (a7ab047) | 258 | all pass | 0 |
| plugin-auth (a7ab047) | 118 | all pass | 1 (a mock `sys_account` row)
|
| service-automation, plugin-sharing, plugin-audit (a7ab047) | all |
all pass | 0 |
| runtime (778bf5c, with objectstack-ai#21545 merged) | 318 | all pass | 0 |

The 8 objectql failures, by cause:
- 2 in `engine.test.ts` (objectstack-ai#3617): the `sys_migration` double declared
only `id`. The production object declares `blocking`, `verified_at` and
`last_run_at`, and `readMigrationFlagVerified` reads those. The fixture
now declares what production declares.
- 5 in `engine.test.ts` / `plugin.integration.test.ts`: red only at the
prior-read probe sites (a fixture's `readonlyWhen` reads an undeclared
`locked`). This fix does not touch prior reads.
- 1 in `driver-fault-boundary-redaction.test.ts`: the mock driver hands
back live references to its store, and an in-place delete corrupted it.
So the fix never mutates a driver row: a row carrying an undeclared key
is copied without it.

In-process readers of undeclared columns, by call site:
- `os migrate plan`'s `unmapped_column` detection introspects the table
through the driver (`driver-sql/src/schema-drift.ts`). Unaffected.
- `os migrate account-issuer` reads `sys_account.issuer` through the
driver (`cli/src/commands/migrate/account-issuer.ts`). Unaffected.
- The `os migrate apply` account preflight
(`cli/src/commands/migrate/apply.ts`, then plugin-auth
`account-identity-preflight.ts`) calls `engine.find` with an explicit
projection naming the retired `issuer`. The engine's unknown-plain
filter already drops that name today. Unchanged by this PR (see
Acceptance notes).
- The metadata column migrations (`packages/metadata/src/migrations/*`)
use driver raw DDL. Unaffected.
- `cloneData` copies every key of its `findOne` source into an insert.
Before this fix, an orphaned column made the clone fail at the write
door (`Unknown field 'mailing_street'`, `INVALID_FIELD`); it now
succeeds. This reader was hurt by the orphaned column, it did not rely
on it.
- Export, search, the RPC dispatcher, `getData` and `findData` all read
through `find`/`findOne`. They narrow with the door, which is intended.
- hotcrm's `scripts/backfill-contact-mailing-address.ts` is external and
reads through REST. The changeset names its interim route.

Hypothesis verdicts:
- **H1 (confirmed):** `find` reached the driver with `fields` undefined,
and driver-sql answered with `select('*')`.
- **H2 (confirmed, and it decided the shape):** on the composed REST
harness, an explicit projection naming a declared field with no column
fails, and driver-sql's ladder returns the whole row, retired column
included (the reach pin's ladder case, red before the fix). So the
engine shapes the rows the driver returns instead of pushing a
projection down. That holds whichever rung answered, and needs no driver
edit.
- **H3:** one authority, the registry field map plus
`PLATFORM_PROVISIONED_COLUMNS`. That list moved into
`declared-read-columns.ts`. The explicit-projection filter
(`find`/`findOne`), the new default projection and the write door's
`undeclaredWriteFieldErrors` all read it. No second list.
- **H4:** the shaping runs before formulas, `expand`, file references
and the hooks. Internal omission, credential masking and the `__search`
strip still run after the hooks. The conformance matrix pins formula,
password mask, `internal`, and each system column.
- **H6:** only `find` and `findOne` return row bodies. `count` returns a
number. `aggregate` returns aggregates, and its `groupBy` names are
gated at the door. `findStream` was retired in 17.0. `expand` re-enters
`this.find`.

## Changes

- `packages/objectql/src/declared-read-columns.ts` (new):
`PLATFORM_PROVISIONED_COLUMNS`, `declaredColumnSet` (no opinion for an
absent, array or empty field map, the rule the read and write doors
already share), and the non-mutating `withDeclaredColumnsOnly` /
`rowsWithDeclaredColumnsOnly`.
- `packages/objectql/src/engine.ts`: `find` and `findOne` shape the
driver's rows right after the driver call. The explicit filter and the
write door read the shared list.
- `packages/objectql/src/no-operator-object-door.ts`: a comment pointer
to the list's new home.
- Tests:
`packages/rest/src/data-query-unprojected-declared-fields.test.ts` (the
reach pin),
`packages/objectql/src/unprojected-read-declared-fields-conformance.test.ts`
(the conformance matrix), and the `sys_migration` fixture in
`engine.test.ts`.
- `.changeset/21571-unprojected-read-declared-fields.md`:
`@objectstack/objectql` minor, BREAKING narrowing, ADR-0087
`not-required (no-migration-prescription)`, and the interim route.
- `content/docs/data-modeling/queries.mdx`: one paragraph stating the
default projection.

## Pins

- **Reach pin.** The harness is the composed REST harness this package
already uses: `RestServer`, then `ObjectStackProtocolImplementation`,
then `ObjectQL`, then a real `SqlDriver` on better-sqlite3 with a file
database. Boot one declares two mailing fields and writes values. Boot
two retires them, the card's upgrade. Then `POST
/api/v1/data/rq_contact/query` with no `fields`.
- Before the fix: 4 failed, 2 passed. The retired columns were present
on the query and on `GET /data/:object/:id`, every-key-declared was red,
and the ladder rung was red. The system-columns pin and the explicit
`INVALID_FIELD` / 400 pin were green.
  - After the fix: 6 passed.
- **Conformance matrix.** `objectql`, three driver behaviours (`whole
row`, `projection`, `ladder`) × doors (`find`, bare `find`, `findOne`,
ladder projection, retired-only projection, `expand`, `findData`,
`getData`, `cloneData`), plus declared-treatment and store-not-mutated
pins. A future driver shape is one entry.
- **Explicit projection** of a retired column still answers
`INVALID_FIELD` / 400. The test asserts `code` and `status`.
- **Pin sweep.** Repo-wide `git grep` over tests for orphaned, unmapped,
retired or undeclared column readings, plus the trim-mode runs above: no
test asserted that an unprojected read returns an undeclared column. The
only fallout was the `sys_migration` fixture, which now declares its
columns. No refusal assertion was changed.

## Reverse verification (fix committed first, at 9593fbd)

Each shaping call was replaced in turn with `node
scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed). Then
objectql was rebuilt (exit 0), and `ablation-dist-preflight --absent`
confirmed the call is absent from all 14 built files.
- `find` call removed: reach pin 3 failed, 3 passed (query,
every-key-declared, ladder). Matrix 17 failed, 19 passed.
- `findOne` call removed: reach pin 1 failed, 5 passed (GET by id).
Matrix 9 failed, 27 passed (`findOne`, `getData`, `cloneData` × 3
shapes). The clone's failure reads `Unknown field 'mailing_street' on
object 'rq_contact'`.
- Restore: `git checkout HEAD -- packages/objectql/src/engine.ts`. Blob
aadf401 equals the HEAD blob, `git diff HEAD` is empty and `git status
--porcelain` is clean. After a rebuild, the preflight finds both calls
present in dist. Reach pin 6/6 and matrix 36/36.

## Local verification, at 4bc22fe (the PR head, after merging main,
which includes objectstack-ai#21545)

- `pnpm --filter @objectstack/objectql test`: 367 files, 7415 passed.
`test:repo`: 1 file, 5 passed. `typecheck` (tsc plus test-typecheck):
exit 0.
- The reach pin: 6 passed. `pnpm --filter @objectstack/rest run
typecheck`: exit 0.
- The full rest suite ran at 99033a9: 259 files, 4889 passed, 326
skipped. The last merge (3 commits) touched neither `rest` nor
`objectql`, so that run was not repeated.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths)
re-derived the same 96 commands at this head. All 96 were run and
reconciled with `--ran` (96 run, 0 unrun).
  - 95 exit 0.
- `node scripts/check-engine-split-ratio.mjs --days 90` answered exit 2:
NOT MEASURED. The clone is shallow inside the 90-day window, and this is
a report-only metric.
- Lint, narrowed and proven:
- Population, from eslint's own config: the 8 changed files went to
`eslint --no-inline-config --format json`. eslint reports the
`.md`/`.mdx` pair as "File ignored because no matching configuration was
supplied".
- Count, from the JSON: 8 files in the report, 6 TypeScript files
linted, 0 errors on them. The 2 warnings are the two ignore notices.
- Invariance: `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move an
untouched file's verdict.
- Not run locally, declared to CI: the remaining downstream consumers of
`@objectstack/objectql`. The trim-mode measurement above covered rest,
runtime, plugin-auth, plugin-sharing, plugin-audit and
service-automation under a superset of this change.

## Acceptance notes

- **Write responses still carry orphaned columns. This is a finding for
the seat, outside this card's read ruling.**
- Measured after this fix on the same harness: `PATCH
/api/v1/data/rq_contact/c1` with `{ name }` answers 200, and its
`record` carries `mailing_street: "1 Retired Way"`.
- Cause: the engine's by-id update returns driver-sql's readback
(`select *`), and `updateData` strips only `internal` fields from it.
- The create 201 and clone 201 bodies are built from driver-sql's
`returning('*')` too. That is not measured here; on a new row the
orphaned columns would read null.
- The A-prime ruling keeps engine write results whole for privileged
writers, so where to cut this is a decision. It is not taken here.
- Prior reads (the update/delete `previous` rows handed to hooks) still
carry orphaned columns. In process only, no public door measured.
Carrier: none.
- In process, `engine.aggregate` does not refuse an undeclared `groupBy`
name. The data door does (`assertGroupByFieldsExist`). Carrier: none.
- The `os migrate apply` account preflight's `issuers` label reads
`(none)` on the engine path, because the engine already drops the
retired `issuer` from an explicit projection. The collision verdict keys
on provider and account ids and is unaffected. `os migrate
account-issuer` reads through the driver and shows real issuers.
Carrier: none.
- objectstack-ai#21573 (the operator-only `os migrate` read of unmapped columns) is
not addressed here. It is the interim route the changeset names.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… sys_organization (objectstack-ai#21628)

Fixes objectstack-ai#21597
Clause-②: no

## What changed

- **`packages/objectql/src/lifecycle/lifecycle-service.ts`**:
`loadGovernance`'s tenant scan now asks
`engine.registry.getObject('sys_organization')` before it reads that
object.
- An **unregistered** `sys_organization` is the single-tenant answer.
There is no read and no tenant override, and the sweep runs one global
pass on each declared window.
- A **registered** one is read exactly as before. The catch still
accepts only `isMissingTableError(error, 'sys_organization')`. Every
other failure still aborts the sweep, and that includes an
`OBJECT_NOT_FOUND` thrown from that read.
- **`LifecycleEngineLike['registry']`** declares the optional
`getObject?(name: string): unknown` member the scan reads, because a
published type must not refuse a key the code below it reads.
- It is optional so that a double modelling only `getAllObjects` stays a
legal engine. Such a registry cannot be asked, and the scan then reads
as before.
- Every real engine has it: `ObjectQL.registry` is the `SchemaRegistry`.
- **`packages/runtime/src/expected-read-refusal-noise.ts`** (the claim's
declared cross-lane path, comment only): the header no longer says the
org probe catches only a missing table. It now says that the probe and
the lifecycle snapshot both ask the registry first, and it states what
the seed-loader accepts.
- **New pins** in
`packages/objectql/src/lifecycle/lifecycle-service.organization-registry.test.ts`:
six cases on a REAL `ObjectQL` engine over a stub driver, so the refusal
the guard avoids is the engine's own and not a double's guess.
- **`.changeset/21597-lifecycle-registry-first-guard.md`**: `patch` for
`@objectstack/objectql` only. The runtime half is measured below.

## Why: the mechanism, measured

- Since `eb9ef791bd`, `ObjectQL.resolveObjectName` throws
`objectNotFoundError(name)` exactly when
`this._registry.getObject(name)` is falsy. Every in-process verb
resolves through it. So `engine.registry.getObject` is the refusal's own
predicate: the guard asks the very question the refusal asks, never a
list of names.
- `getAllObjects` was not used for this. It is a full merging walk with
a side effect, and it can disagree with `getObject`'s short-name and FQN
index. The dangerous direction of that disagreement is "absent" for an
object that resolves.
- **Premise at base `36ad3210d4`:** the new file was run before the fix
and gave 5 passed, 1 failed.
- The premise case shows the engine refusing `find('sys_organization')`
with `code: 'OBJECT_NOT_FOUND'`, `status: 404` and `object:
'sys_organization'`, with no driver read at all.
- The unregistered case fails on the scan having read `sys_organization`
(`expected 1 to be +0`).

## Which shape was followed, and why

No shared helper exists: there is no registry-presence helper in
objectql, core or types. objectstack-ai#21545 left two shapes:
- the engine probe's **registry presence before the read**
(`probeInstallOrganizations`);
- the seed-loader's **catch-side refusal attributed on `object`**
(`resolveSoleOrganizationId`).

This follows the engine probe's shape, with its catch unchanged. It is
spelled with `MigrationRecoveryPlugin`'s capability check (`typeof ...
=== 'function' && !...`), because `LifecycleService` holds a duck-typed
engine rather than the registry itself. It is not the seed-loader's
catch-side shape, for three reasons:
1. The card's ruling names the registry-first guard.
2. On a real engine the registry question makes a catch-side
`sys_organization` arm unreachable, so adding that arm as well would
make a third variant.
3. An `OBJECT_NOT_FOUND` that arrives after the registry said
"registered" either names another object or contradicts the registry. It
propagates, and this is pinned.

There is no new engine API and no `engine.ts` edit. The registry comes
from the engine's existing `registry` accessor, which settles H3.

## Pins (real `ObjectQL`, `engine.find` spied with call-through)

| case | asserts |
|:--|:--|
| control: registered, provisioned, one org with a 90d tenant override |
scan reads once (reaches the driver); tenant pass at 90d, then the
global 30d pass; no error |
| premise: unregistered | the engine refuses the read:
`OBJECT_NOT_FOUND`, 404, `object: 'sys_organization'`; the driver saw
nothing |
| **unregistered: single-tenant sweep** | `report.errors` empty; zero
scan reads; one global 30d pass; `report.swept` records the 30d cutoff;
no warn |
| registered but unprovisioned (`no such table`) | the scan reads and
the driver throws; global pass; no error (unchanged missing-table
answer) |
| real driver fault (`ECONNREFUSED`) | the scan's read rejects with
`code: 'ECONNREFUSED'`; no candidate read, no delete, nothing swept;
`report.errors` and the warn quote that fault's own message |
| `OBJECT_NOT_FOUND` attributed to ANOTHER object (a `beforeFind` hook
reads an unregistered `sys_org_unit`) | rejection `OBJECT_NOT_FOUND`,
404, `object: 'sys_org_unit'`; the sweep aborts; not read as absence |

## Verification, at final head `190cea2e9e`

- **Reverse verification.** The fix was committed first, then the guard
was ablated through `scripts/ablation-replace.mjs`: the anchor hit once,
and the blob went `a3ff3c2d4d3d` to `6c3fa03e505d`. Result:
`src/lifecycle/` gave **1 failed, 115 passed (116)**. Only the
unregistered pin went red, and it reported the defect itself:
`governance snapshot could not be loaded (Object 'sys_organization' not
found) — sweep aborted before any policy was applied`.
- The restore was `git checkout HEAD -- ABSPATH`. The blob is back at
the HEAD blob `a3ff3c2d4d3d`, `git diff HEAD` is empty and `git status
--porcelain` is empty.
- The first run of the same ablation, at `dfb2af2144`, gave the same
direction.
- The 110 pre-existing lifecycle tests stay green with the guard
ablated. Their doubles have no `getObject`, so they never reach the
guard.
- **Tests.**
- `pnpm --filter @objectstack/objectql test`: Test Files 368 passed
(368), Tests 7421 passed (7421).
  - `src/lifecycle/`: 2 files, 116 passed.
- **Typecheck.** `pnpm --filter @objectstack/objectql typecheck` exit 0,
with `check:test-typecheck: OK`. The new file is in the test program
(`tsc -p tsconfig.test.json --listFilesOnly`: 1 hit) and compiles with
zero errors.
- **Import side.** objectql's published `.d.ts` gains the optional
member. Three test files in other packages build typed
`LifecycleEngineLike` doubles, all `registry: { getAllObjects }` and
none passing `getSettings`.
  - `pnpm --filter @objectstack/service-messaging typecheck` exits 0.
- Reverse leg: pasting `getObject: 42` into its double gives `TS2352 ...
The types of 'registry.getObject' are incompatible` against the rebuilt
`.d.ts`. The same paste passes an `as` assertion against the old type,
so this proves the consumer read the rebuilt declarations. Restore
proven.
- **Runtime.**
  - `pnpm --filter @objectstack/runtime typecheck` exit 0.
- `src/expected-read-refusal-noise.channel-asymmetry.test.ts`: 4 passed.
- The edited module is not in runtime's published files. After a build,
its header text and its export `captureExpectedReadRefusals` have 0 hits
in `dist/`, and the module is 0 of 79 `sources` in both sourcemaps. The
positive control `migration-recovery-plugin` is 1 of 79, and its export
hits 4 `dist` files. So the changeset carries no runtime entry.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 64
commands at `190cea2e9e`, and all 64 exited 0. `--ran` reconciliation:
64 derived, 64 run, 0 NOT-MEASURED (a derived zero, every exit code
recorded).
- `check:query-options-erasure` was red once on the first head (test
surface 236 to 238: two `as any` query options in the new file). Both
are typed now, and the ratchet holds at 236.
- **Lint (a proven narrowing; `pnpm lint` is CI's).** `eslint
--no-inline-config --format json` was run on the three TS files in the
diff: 3 files, 0 errors, 0 warnings. The changeset `.md` is outside
eslint's configuration ("no matching configuration").
`eslint.config.mjs` states that it never enables type-aware linting, so
this diff cannot move any untouched file's verdict.

## Acceptance notes

- **Boundary of the ruling, noted, not filed.** Take a composition that
registers no `sys_organization` while its database still holds an
organization table written by another composition. It now sweeps every
tenant on the global window. That is the card's stated semantics, and it
is the same answer `probeInstallOrganizations` gives. Since `eb9ef791bd`
no in-process verb can read that table by its raw name anyway.
- **Clause line.** `Clause-②: no` is copied from the claim. The only
type change is the optional member on the published input type
`LifecycleEngineLike['registry']`. Every engine accepted before is still
accepted, and there is no new export. Precedent: commit `0f38ab084`
added the optional `tenancy` key to `LifecycleObjectLike`, and it
shipped as an objectql `patch`.
- **Branch base.** The branch is 2 commits behind `origin/main`
(`f97660cdd6`). Neither commit touches objectql, the lifecycle or the
runtime header, so the branch is not merged here.
- **Worker count.** The full objectql run passed `--maxWorkers=2` after
a bare `--`, which vitest drops, so it ran at vitest's default worker
count. It is still a whole-package measurement.

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

---------

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

Projects

None yet

2 participants