Skip to content

feat(plugin-auth,objectql,metadata-protocol,runtime)!: under single the Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) - #22186

Merged
objectstack-fleet[bot] merged 25 commits into
mainfrom
claude/issue-15195-default-org-load-bearing
Oct 8, 2026

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #15195
Clause-②: yes (narrowing)

ADR-0131 C1: the Default Organization is load-bearing under single. Scope as triage ruled it: 6040634630 (Q1 → B) for the implementation, 6053354661 (Q1 → A, Q2 → A) for landing it. This is one atomic, cross-lane PR. It carries the @objectstack/verify bootStack re-pin and the dogfood re-pins with the implementation. packages/qa/dogfood and packages/verify (domain:cli) join it, declared on #6024.

What this does

  • The boot invariant (D3). Under the single posture, AuthPlugin.start() finds or creates the Default Organization (slug: 'default'), with or without a platform admin. A failed read or insert throws and fails the boot.
    • AppPlugin declares com.objectstack.auth as an order-if-present dependency, so the kernel starts the auth plugin first wherever a host registered it.
    • The organization therefore exists before the inline seed and before kernel:listening.
  • The seed-exemption withdrawal (D3/D9). The seed loader stamps the install's organization on every row of an object that carries an organization_id column, sys_ / cloud_ / ai_ seeds included.
    • Suppose no organization is pinned and none can be derived (zero organizations, or several), on an install that registers the organization object. Such a row is refused, counted and named.
    • An object with no organization column is never stamped.
  • The owner pin (D3 / ADR-0093 D7). The reconciler binds the first admin as member before the owner bind learns who they are.
    • While the once-only bind is undecided and the Default Organization has no owner, the bootstrap promotes that row to owner in place.
    • It records the decision as promoted.
  • The derivation rule for the objects already in scope (D9). resolveSystemInsertOrganization derives at exactly one organization. It refuses at zero (new: reason: 'no-organization'), at several, and under a wall.
    • The refusal reuses ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED (status 500).
    • No packages/spec edit.
  • bootStack boots the production single shape (D3 / D11). The harness no longer pins the owner bind off (autoDefaultOrganization: !!opts.orgContext is gone). Every boot has the Default Organization, every sign-up is its member, and the harness admin is its owner, as objectstack dev / serve boot it.
    • orgContext is now only the vacuity guard: it asserts that the admin's session carries an organization, and refuses the boot otherwise. It still refuses to compose with multiTenant: 'posture-only'.
    • There is no test-only org-less mode and no escape hatch.
  • Unchanged by ruling.
    • The 49 gated platform objects keep isPlatformObjectOutOfTenantAuditScope until C8.
    • The lean-install branch is unchanged, with a pin. That is probeInstallOrganizations answering [] when no organization object is registered.
    • No C3, C5 or C6 surface, no seeder and no applyTenantScope is touched.

Boot order: fresh single, examples/app-showcase through bootStack (measured)

Base 51290bca2c, implementation head c49f46bab1. Seeds: 132 rows over 19 objects. The last two rows were measured with the harness at its old pin. Since this PR, bootStack always boots the owner-bind-on row.

step base head
AuthPlugin.start() no organization Default Organization inserted: the first insert of the boot (seq 0 of 98)
AppPlugin.start() inline seed 132 rows, all organization_id NULL (sys_business_unit 5 of 5 NULL) 132 rows, 0 NULL, sys_business_unit included
kernel:ready 0 organizations 1
kernel:listening 0 organizations (old harness default). 1 only when a dev admin existed at kernel:ready; even then the organization was the 49th insert, after every seed, and 130 of 132 seed rows stayed NULL 1
dev admin, owner bind on (bootStack now, always) org created and admin bound owner directly reconciler binds member; the bootstrap logs "promoted the platform admin to owner"; isPlatformAdmin: true, positions platform_admin, org_owner
dev admin, owner bind off (the old harness default, removed) no membership, no active organization member of the Default Organization, the session's active organization

The derivation rule: resolveSystemInsertOrganization, objects in scope

posture organizations organization object registered write carries one base head
single 1 yes no derived derived
single 0 yes no lands NULL (measured, probe) refused no-organization (measured, pins)
single 2 or more yes no refused ambiguous-organization unchanged
single 0 no (lean composition) no lands unstamped unchanged, pinned
isolated / group any yes no refused walled-posture unchanged (measured on a posture-only isolated showcase boot: code ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED, status 500; the same insert carrying tenantId lands stamped)
any any any yes stamped with it unchanged
the 49 gated platform objects any any no exempt unchanged (C8)

Acceptance pins (implementation head 4fc2472fd0, base ec8f37c890)

runtime/src/default-organization-boot-invariant.pin.test.ts boots a real kernel twice:

  • Kernel A: a fresh deployment nobody signed up to.
  • Kernel B: objectstack dev, where the dev admin is created after the organization exists.

AppPlugin is registered before AuthPlugin on purpose.

pin where base head control
(a) organization before the listener and before the first seed row; an isSystem insert with no organization lands stamped kernel A, 3 cases red green an object declaring tenancy: { enabled: false } takes none
(b) isolated / group refuse, with the code and status; the insert carrying tenantId lands objectql system-write-organization.test.ts and the measured isolated boot above green (since #8844) green the carrying insert
(c) the first admin is the owner, by promotion kernel B red green isPlatformAdmin read back unchanged
(d) every seed row stamped, sys_ seeds included kernel A red green the tenancy-off seed lands with no organization
(e) lean-install branch unchanged objectql and metadata-protocol [ADR-0131 Q2, held as-is] cases green green the registered-but-empty case refuses

Reverse verification of the implementation (from committed 4fc2472fd0):

  1. The eight implementation source paths were restored to ec8f37c890 in the tree only.
  2. The four packages were rebuilt, and scripts/ablation-dist-preflight.mjs confirmed each head marker absent from dist/.
  3. The pin run: 5 failed and 2 passed, as tabled.
  4. A trap restore, proven by git diff HEAD empty and per-path blob hashes equal to HEAD.
  5. A rebuild with the markers back.

Ablations (scripts/ablation-replace.mjs, each restored and rebuilt):

  • The promotion call disabled: (c) turns red (member, not owner).
  • com.objectstack.auth removed from AppPlugin.optionalDependencies: every seed row is refused, and (a)'s ordering case and (d) turn red.

M5: the existing tests C1 moved, judged one by one

Each flip was judged against the ruling and none was restored. There are two judgements:

  • flipped: the old expectation was the defect C1 fixes, and the pin now holds the new answer.
  • re-pinned: the fixture changed and the subject did not.
file package judgement what changed
seed-loader-sole-organization-read-failure.test.ts metadata-protocol flipped no organization, several, or an unprovisioned table no longer write the seed row NULL
seed-loader-org-fallback.test.ts objectql flipped sys_ seeds take the organization; ambiguity refuses
engine-organization-probe-outage.test.ts (cause 2) objectql flipped the empty probe still is not an outage, but zero organizations now refuses
system-write-tenancy-autonumber-split.integration.test.ts runtime flipped the first-boot write is refused at zero organizations, with the stamped control
auth-plugin.test.ts, "autoDefaultOrganization: false opts out" plugin-auth flipped the organization exists anyway
seed-loader-engine-schema-fallback.test.ts metadata-protocol re-pinned the one new engine read is the organization-object registration read
seed-loader-existing-records-read-failure.test.ts metadata-protocol re-pinned the metadata double no longer claims an organization object
protocol-publish-package-drafts.test.ts objectql re-pinned the install holds its Default Organization
packages-seed-apply-disclosure.test.ts, packages-seed-apply-read-decorations.test.ts, http-dispatcher.test.ts runtime re-pinned each install holds its Default Organization
seed-tenancy-autonumber-split.integration.test.ts runtime re-pinned the split is reproduced from pre-C1 residue written at the driver, since the loader can no longer produce it; a new case pins the refusal
auth-plugin.test.ts, the two app:seeded cases plugin-auth re-pinned they delete the organization to reach "no target"
status-mirror-cascade.integration.test.ts plugin-approvals re-pinned The rig registered sys_organization but left it unprovisioned and empty, then system-inserted opportunity, so CI refused it (no-organization). It now provisions sys_organization and seeds the Default Organization before the first insert. sys_organization leaves the expected-absent probe list, by that channel's own contract (a table that started resolving is provisioned now). The subject, an approval decision cascading as the deciding user, is unchanged. The delegation channel still fires (afterAll green).
claim-seed-ownership-warm-boot.test.ts plugin-security re-pinned The rig registered the real identity objects with no auth plugin, so CI refused the crm_case seed inserts in four cases. Every boot of the rig now finds or creates the Default Organization, as the invariant does on every boot. The subject, the warm-boot seed-ownership claim and its target, is unchanged: all 5 cases pass with the same expectations.
runas-system-stamping.integration.test.ts, the SecurityPlugin flip block service-automation re-pinned The rig composed the identity objects with no auth plugin, so the user-less run's create_record was refused (found by the sweep below; CI never reached this suite). The rig now seeds the Default Organization as org_1, the member's organization. The NULL-born case also pins the row's derived organization (org_1), so the 403 is decided by the stamp columns alone, which is the subject. The lean block above it registers no organization object and is unchanged.

None of these weakens the no-organization refusal, adds tolerance in the engine, or skips a case.

bootStack and the dogfood: the 24 re-pinned files

At this PR's implementation alone, 24 dogfood files failed; at base they pass. Each was re-pinned on its own cause, to the production single shape. Pins that C1 flips flip to the new answer and none is deleted. The shared helper is leaveOrganization (new, test/armed.ts): it deletes the user's sys_member rows in system context, signs in again, and throws if a membership survives. A user removed from their organization is an ordinary production state, not a test mode.

file cause what changed why it keeps the original intent at the production shape
analytics-adhoc-query-isolation its memory leg: driver-memory refuses a tenant-scoped read (503), and every session now carries the Default Organization the memory leg became an objectql-strategy leg on sqlite-wasm, with the analytics plugin's queryCapabilities withholding native SQL. Restart-when: #15212 closed the file pins isolation under both analytics strategies; the memory driver was only the route to the ObjectQL strategy, and the capability switch reaches it on a driver that answers
analytics-contains-membership as above as above as above
analytics-inline-dataset-admission as above as above as above
analytics-inline-dataset-isolation as above as above as above
armed the "outside" class was an org-less sign-up; every sign-up is now a member orgless/orgbound renamed outside/inside; the outside principal leaves the organization; the write-floor disarm text names "Keep the principal a member of the organization" the floor is still measured on a principal with no active organization against one inside it
delegated-admin-invite it minted its own slug: 'default' organization (DuplicateRecordError) reads the boot's Default Organization same subject inside the deployment's one organization
delegation-of-duty the delegator is now a member, so the gate reads the delegator's organization's positions (ADR-0091 D3 rule 5), and the fixture's positions had none sys_position / sys_user_position rows carry the Default Organization; the session double carries activeOrganizationId same delegation rules, on rows held the way an org-bound deployment holds them
invitation-ledger-row-scope it minted a second organization (acme-8095) while the reconciler binds every sign-up to the Default Organization uses the Default Organization the ledger's row scope is measured inside the deployment's one organization
membership-actor-attribution minted its own default organization reads the boot's unchanged subject
membership-ended-session-revoke minted its own default organization reads the boot's unchanged subject
membership-reconciler minted its own default organization reads the boot's unchanged subject: the reconciler binding through the real sign-up pipeline
membership-role-vocabulary minted its own default organization reads the boot's unchanged subject
org-admin-affordance-reach it minted reach-org while sign-ups bind to the Default Organization uses the Default Organization grades measured in the organization the members are in
organization-delete-federated-fixture deleting the Default Organization now runs the delete behaviour of every row the deployment owns, the seed included deletes a second organization (org-21910) that the admin owns and no row belongs to the cascade scan probes every reference on any organization's delete, so the federated anchor is still reached, without a different question attached
parent-derived-write-refusal-not-visible an org-less principal was the precondition boot(inside): the outside principal leaves the organization; the inside boot keeps orgContext: true same refusal shape, for a principal outside the organization
permission-set-lock-row-provenance the harness admin was a member no edit: the harness owner bind the admin is the deployment's administrator, the Default Organization's owner as objectstack dev boots it
permission-set-write-through-package-binding as above no edit as above
predicate-write-unreadable-not-matched org-less precondition as parent-derived-write-refusal-not-visible as above
sharing-rule-org-less-caller the org-less personas, and the harness admin as the org-less platform operator the org-less personas leave the organization. The operator is a new user holding admin_full_access globally who leaves it. The control persona's Default membership is removed before its tenant-A one. Preconditions judge live sessions only, because leaving revokes the sign-up session (#15784) a sharing rule still must not widen reads for a caller with no organization; each caller is now a production shape
showcase-permission-projection the harness admin was a member no edit as permission-set-lock-row-provenance
single-tenant-identity-create the old expectation, a sys_business_unit with no organization, is the defect C1 fixes flipped: organization_id equals the Default Organization's id ADR-0057's property (creatable single-tenant, no VALIDATION_FAILED) is still the pin
sys-file-metadata-write-refusal the harness admin was a member no edit as permission-set-lock-row-provenance
two-doors-permission the harness admin was a member no edit as permission-set-lock-row-provenance
write-door-unreadable-is-not-found org-less precondition as parent-derived-write-refusal-not-visible as above

Files beyond the declared set (packages/verify/src/harness.ts and the 24 files):

  • packages/qa/dogfood/test/armed.ts, which holds the leaveOrganization helper.
  • packages/verify/src/harness.org-context.test.ts. The default-boot case now pins the production shape, including the admin's owner membership.
  • showcase-external-autoconnect.dogfood.test.ts and showcase-scope-depth.dogfood.test.ts: comments only, correcting the description of the removed org-less boot.
  • .changeset/15195-verify-bootstack-production-single.md.
  • Cross-lane (domain:services) test fixtures, round 3, tabled under M5: packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts, packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts and packages/services/service-automation/src/runas-system-stamping.integration.test.ts.
  • The tenant-audit census (content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md). After each main merge it was regenerated with the gate's own write mode, outside the MERGE state, and the prose figures the gate holds were moved with it: 234 → 236.

Reverse verification of the harness change (committed 05dedeea79, and again on 9aee4d05f1 with identical results; round 3 changes no file it reads):

  1. scripts/ablation-replace.mjs (WRAP mode) restored the old pin in harness.ts, with a string-literal marker (REVERSE-15195-OLD-PIN) the bundler keeps.
  2. @objectstack/verify was rebuilt, and ablation-dist-preflight confirmed the marker present in dist/.
  3. The mutated leg turned red:
    • dogfood, the 24 files: 5 files and 14 tests failed, 204 tests passed. The five are exactly the "no edit" rows above.
    • harness.org-context.test.ts: 1 of 5 failed (expected [ 'member' ] to deeply equal [ 'owner' ]).
  4. The file was restored: blob equal to HEAD and git diff HEAD empty. @objectstack/verify was rebuilt, and the preflight --absent passed.

The same leg on c2f7e36d6e left the harness unit test green, because its two assertions hold without the owner bind. 05dedeea79 adds the owner assertion so the unit test reads the line itself. The first attempt was void and is not counted: the tool refused it because the replacement contained the anchor, and nothing ran.

The in-memory driver until C8 (triage Q2 → A)

@objectstack/driver-memory refuses tenant-scoped reads. Under single, every session now carries the Default Organization, so on that driver signed-in reads answer 503 until C8 (#15212, ADR-0131 D8) gives single no read predicate. The plugin-auth changeset states this.

This is not new in production terms. At base, a showcase boot with the owner bind on (the shape objectstack dev boots) already answered the platform admin's GET /data/showcase_category with 503 SERVICE_UNAVAILABLE on the memory driver. Only the harness's org-less pin kept the four analytics memory variants green. Those variants now run on the SQL in-memory driver, with Restart-when: #15212 closed in each file.

Clause-②: the built entry declarations, base ec8f37c890 against head

  • @objectstack/objectql:
    • SystemWriteOrganizationDecision's 'no-organization-yet' becomes 'no-organization-object'.
    • SystemWriteRefusalReason gains 'no-organization'.
    • resolveSystemWriteOrganization takes a required organizationObjectRegistered.
  • @objectstack/plugin-auth:
    • EnsureDefaultOrganizationOnceOptions gains an optional organizationCreatedByThisProcess.
    • EnsureDefaultOrganizationResult gains an optional ownerPromoted and the 'owner_promotion_failed' reason.
  • @objectstack/verify: no declaration change. bootStack now boots an org-bound admin for every caller, so a fixture that relied on an org-less one breaks.
  • @objectstack/metadata-protocol, @objectstack/runtime: no public declaration changes.

The accept sets narrow, so the answer is yes (narrowing). The objectql, plugin-auth, metadata-protocol and verify changesets are minor, with the BREAKING banner and an ADR-0087 disposition. The runtime changeset is patch. Driven offline with this body as the pull_request payload (--event), check-changeset-no-major reads Clause-②: yes (narrowing) and passes the level axis. check-adr-0087-registration also passes.

Verification (head ede1fbd345, merge base 9f0de32a03)

Every package that depends on @objectstack/objectql was run in full. That is 45 packages (pnpm --filter '...@objectstack/objectql'); 44 have a test script, and metadata-protocol was added. A package that does not register sys_organization cannot reach the no-organization refusal, but the sweep measured every package rather than relying on that argument. In each log, every SystemWriteOrganizationRequiredError / no-organization match was read; none remain after the fixes.

  • Round 3, the three touched packages, at ede1fbd345 (plugin-approvals and plugin-security also ran at b7d0b3469e, which already carried their fixes):
    • plugin-approvals: 62 files, 905 passed.
    • plugin-security: 179 files, 3,775 passed, 45 skipped.
    • service-automation: 175 files, 2,120 passed.
  • runtime, re-run at ede1fbd345: 339 files, 5,495 passed, 19 skipped.
  • cli: unit tier 264 files, 3,915 passed. Integration tier in four slices: 93 files, 900 passed, 2 skipped.
  • The rest of the sweep, at b7d0b3469e / ede1fbd345:
package files tests
plugin-audit 40 641
plugin-sharing 40 1,002
trigger-record-change 11 114
trigger-schedule 8 174
service-analytics 180 4,441 (262 skipped)
service-datasource 41 760
service-knowledge 4 49
service-messaging 48 534
service-settings 39 707
service-storage 43 717
service-queue 5 77
service-sms 5 74
rest 262 4,938 (326 skipped)
hono 5 122
http-conformance 8 102
downstream-contract 3 31
client 51 653
client-react 3 34
cloud-connection 41 505
connector-mcp / -openapi / -rest / -slack 3 / 4 / 4 / 3 23 / 36 / 26 / 10
driver-mongodb 31 (5 skipped) 690 (182 skipped)
knowledge-memory / knowledge-ragflow 1 / 1 8 / 10
organizations 11 151
plugin-dev 9 86
plugin-email 31 535
plugin-pinyin-search 2 21
plugin-webhooks 15 165
example-crm / -embed-objectql / -showcase / -todo 5 / 1 / 33 / 7 45 / 2 / 408 / 238
  • At 9aee4d05f1. Round 3 changes no file these read:
    • dogfood, all 8 shards (OS_TEST_SHARD=k/8): 222 files (1 skipped), 1,753 tests passed, 9 skipped.
    • objectql: 381 files, 7,527 passed.
    • metadata-protocol: 222 files (3 skipped), 28,283 passed.
    • plugin-auth: 128 files, 2,655 passed.
    • verify: 18 files, 133 passed.
  • Typecheck: green for plugin-approvals, plugin-security and service-automation at ede1fbd345 (check:test-typecheck OK: the plugin-approvals debt ledger is held, the other two ledgers are empty). Earlier: objectql, runtime, metadata-protocol, plugin-auth, verify and dogfood.
  • Gates: dispatch-gates --commands derived 112 for this tree (two i18n families joined), all 112 ran with exit 0, and --ran reconciled 112 of 112 with recorded exit codes. The roster gates whose list lives in a touched directory all exit 0. check-single-claim-paths passed with this PR's context.
  • Lint: eslint --no-inline-config over the 51 script and TypeScript files this diff adds or modifies: 0 errors and 0 warnings, 51 files in the JSON report. The config enables no type-aware linting, so this diff cannot move an untouched file's verdict. The full pnpm lint run is CI's.
  • Serial: feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 and feat(spec)!: PROTOCOL_VERSION 17 → 18 in an ordinary PR — regenerated spec-changes.json and upgrade guide, ^18 handshakes, pre-mode lockstep exception (#22085 Q1 → B) #22215 are still open. Neither shares a file with this PR; no dogfood showcase file and no showcase-security.ts is touched.
  • main since the merge base: 27 commits, not merged here. git merge-tree against it is clean. 16 test files they add or change name the organization object (for example plugin-security/src/grant-holder-membership-refusal.test.ts, service-settings/src/settings-organization-isolation.pin.test.ts and dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts). They are NOT MEASURED against C1 here; CI's merge-ref run measures them.

Acceptance notes


Generated by Claude Code

claude added 14 commits October 7, 2026 15:37
…tion (ADR-0131 D9)

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
… invariant, owner promotion, seed stamping (ADR-0131 D3/D9)

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…boot tests to ADR-0131 D3/D9

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…nternal, stamp only seed rows that carry an organization, real-kernel pin

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…nciler binds the first admin before the owner bind

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…irst boot, the measured Clause-② line

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…the tenant-audit census counts the two new system writes

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…lls; the pinned-double ledger records the auth-plugin update double

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
… the seed-stamp double refuses a WHERE combinator

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits October 8, 2026 06:36
…fault-org-load-bearing

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…e, with the two system writes C1 adds (234 to 236)

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

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/plugin-auth, @objectstack/runtime, @objectstack/verify, touching 44 documentable anchor(s).

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6729e107e806b148d809e42b62e7fc0233d3d324.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (5 routes)
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages), organization_id (literal, 33 pages)
  • 5 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 — 46 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 6729e107e806b148d809e42b62e7fc0233d3d324 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6729e107e806b148d809e42b62e7fc0233d3d324

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

claude added 4 commits October 8, 2026 06:40
…keeps only the vacuity guard

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…boot, each on its own cause

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…equence until C8; two harness comments corrected

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
claude added 4 commits October 8, 2026 10:46
…fault-org-load-bearing

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…in merge, with the two system writes C1 adds (234 to 236)

Generated tables written by `node scripts/tenant-audit-census.mjs --write` on the
committed merge; the hand-written prose figures the gate holds to the census
moved with them (erased receivers 48 to 50, decidable 154 to 156, elevated 123 to
125, population 234 to 236).

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…uction single shape — the Default Organization exists

Both rigs register `sys_organization` and then system-insert a tenant-scoped
row on an install that holds no organization: the shape ADR-0131 C1 refuses
(D9, `no-organization`) and no production `single` boot reaches, because the
auth plugin's boot invariant creates the Default Organization first (D3).

- plugin-approvals `status-mirror-cascade.integration.test.ts`: provisions
  `sys_organization` and seeds the Default Organization before the first
  insert; `sys_organization` leaves the expected-absent probe list, by that
  channel's own contract (a table that started resolving is provisioned now).
- plugin-security `claim-seed-ownership-warm-boot.test.ts`: every boot finds or
  creates the Default Organization, as the invariant does on every boot.

The subjects (the approval cascade's identity, the warm-boot seed claim) are
unchanged; the fixtures changed.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…lt Organization, the member's own

The rig composes the identity objects (so `sys_organization` is registered) and
no auth plugin, then runs a user-less system flow whose `create_record` is a
system insert into a tenant-scoped object: refused under ADR-0131 C1 (D9,
`no-organization`), a shape no production `single` boot reaches. The rig now
seeds the Default Organization as `org_1`, the member's organization, and the
NULL-born case pins the row's derived organization so the 403 is decided by the
stamp columns alone. The lean rig above (no organization object) is unchanged.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ede1fbd3459d826e884d5c5ba3e3e0476edd0961
Local-runs: none

Isolated, adversarial read of PR #22186 at the head above (card #15195, ADR-0131 C1). Inputs: the card's body and all 13 comments (triage's scoping 6040634630 Q1 → B, triage's landing ruling 6053354661 Q1 → A / Q2 → A, the take-over claim 6049583616 and its revision, the three os-dev-reports 6052898415 / 6059821487 / 6064009112), ADR-0131 and ADR-0093 D7 as they stand on main (4e4111ca05), the PR body as rewritten at round 3, the 59-file list, the net diff against the merge base 9f0de32a03, the cross-lane declarations on #6024 (6042937973, 6053967589) and #6021 (6039104553, 6064050477), and the check-runs on the head. Nothing was built, run or re-run.

① Derived judgments

The narrowing, against triage's scope (Q1 → B). Each piece judged against ADR-0131's text:

  • Boot invariant (D3) — right. AuthPlugin.start() calls the new internal ensureDefaultOrganizationExists when the posture in force is single: find by slug: 'default', else the sole organization, else insert; a failed read propagates and a failed insert throws BOOT REFUSED …. That is D3's "a failure becomes a boot error", and it no longer waits for an admin. Several organizations with no default → nothing created, a warning, and D9's refusal governs the writes (§5: nothing defaults to any other owner). Not gated on autoDefaultOrganization, which now governs only the owner bind — the option's docblock says so and the changeset carries the BREAKING sentence. Pinned on a real kernel (default-organization-boot-invariant.pin.test.ts, kernel A: the organization is the only row at kernel:listening on a boot nobody signed up to, and its insert precedes the first seed row).
  • AppPlugin order-if-present on com.objectstack.auth — right. optionalDependencies gains the auth plugin's declared name (auth-plugin.ts:306), so the kernel starts it first wherever a host listed it; the pin registers AppPlugin before AuthPlugin on purpose and the body's ablation shows the pin reds without the declaration. A composition without the auth plugin is unaffected.
  • Seed-loader stamp (D3/D9) — right. The /^(sys_|cloud_|ai_)/ exemption is gone; resolveSoleOrganizationId now answers sole / unowned / no-organization-object, and loadDataset stamps every row of an object whose registered schema carries organization_id (seedRowNeedsOrganization: authored column or the injected tenant column, and not tenancy: { enabled: false }) — sys_ seeds included — or REFUSES the row when the install registers the organization object and holds zero or several (counted in errors, named, logged once per dataset at error, nothing written). A column-less object is never stamped, pinned or derived (this also corrects the inherited defect where a stamped column-less row was lost at the engine). A row carrying its own organization_id wins. Pinned in seed-loader-organization-stamp.test.ts with the discriminating controls (pinned org, author-named org, tenancy-off object) and on the real kernel (pin d: the sys_business_unit seed carries the organization; the tenancy-off seed carries none).
  • Owner promotion (ADR-0093 D7) — right. D7 keeps the owner bind with ensureDefaultOrganization and says the reconciler-first order must be safe. With the organization now predating the admin, the reconciler always binds member first; promoteReconciledMemberToOwner promotes that one row in place only when the once-only bind is undecided, the admin holds exactly one membership, on the Default Organization, with role member, and that organization has no owner — each condition pinned (decided bind, existing owner, other role, other organization all leave the row alone). The ledger records promoted; a failed promotion reports at error and leaves the bind undecided. organizationCreatedByThisProcess admits the bind on a ledger-less kernel for the organization this process created, which is the fresh-install case the gate already admitted. Kernel B pins the end state (one owner row, isPlatformAdmin unchanged).
  • resolveSystemInsertOrganization (D9) — right, within scope. resolveSystemWriteOrganization derives at exactly one organization; refuses at several (ambiguous-organization, unchanged), under a wall (walled-posture, unchanged, no probe), and now at zero where the composition registers the organization object (no-organization). The isPlatformObjectOutOfTenantAuditScope early return above it is untouched at the head (read at engine.ts:5841), so the 49 gated platform objects keep their exemption until C8 as 6040634630 rules. No seeder, no sys-metadata-repository, no plumbing column, no applyTenantScope and no platform-object-tenancy.ts is in the diff; packages/spec is untouched.
    • Status 500 — right. ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED is already registered (packages/spec/src/api/error-code-ledger.zod.ts:684) with the ledger's own rationale: neither the client's bad input nor a missing request precondition. The new reason shares that class exactly — reaching it under single means the boot invariant was broken after boot (organization deleted) or the composition never booted the auth plugin, which no caller can repair by changing its request. The message names what is missing (ADR-0123 D4). No new code, so no ledger gap and no spec-lane edit.
    • Lean-install branch — unchanged and pinned. probeInstallOrganizations is untouched; the one new fact, organizationObjectRegistered: !!this._registry.getObject(ORGANIZATION_OBJECT), is what separates the two readings of an empty probe. Pinned [ADR-0131 Q2, held as-is] four times: the pure function, the engine with lean: true (never asks the driver), and the seed loader twice (no organization object; the engine refusing the unregistered name). Whether D9 reaches that composition stays C8's (feat(spec,drivers,objectql,plugin-security): organization_id NOT NULL per cleared table; one predicate for Layer 0 and every driver; both orWhereNull arms, the __global__ sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212), as 6040634630 records.

The public surface, on the built entry declarations. The dev's list is right, and two of its objectql items are NARROWINGS of a published TypeScript surface, not widenings — named here so the record says so:

  • @objectstack/objectql: SystemWriteOrganizationDecision loses the member 'no-organization-yet' (a consumer branching on it now has a dead branch or a type error) and gains 'no-organization-object' with a narrower meaning; resolveSystemWriteOrganization gains a REQUIRED organizationObjectRegistered (an existing caller fails to compile — deliberately, the docblock says, so no caller inherits the permissive reading by omission); SystemWriteRefusalReason gains 'no-organization' (a widening).
  • @objectstack/plugin-auth: an optional organizationCreatedByThisProcess on EnsureDefaultOrganizationOnceOptions, an optional ownerPromoted and the 'owner_promotion_failed' reason on EnsureDefaultOrganizationResult — widenings only, as its changeset says.
  • @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/verify: no declaration change; bootStack's behaviour changes.

Clause-②: yes (narrowing) describes this truly: yes because a published surface moves (the pm-dispatch criterion is "widens the accept set or expands the public surface", and plugin-auth and the refusal-reason union expand), (narrowing) because the accept sets and the objectql surface narrow — the arm AGENTS.md makes BREAKING. The #22223 bullet ("a card that narrows a published accept set is no") describes a card that narrows and nothing else; this one also expands the surface, so the body's spelling is the right one and the level-axis gate reads it (Check Changeset green at 16:14Z, after the body write). The objectql changeset names both narrowings (the rename and the required parameter) and the one-line remedy for the parameter; the rename's FROM → TO sits inside the ADR-0087 disposition comment rather than the rendered body — greppable in the shipped CHANGELOG.md, which is what the rule asks, but a visible sentence would serve a human reader better (non-blocking).

The re-pinned tests. 15 it( lines are removed in the diff; each is replaced by a case that pins the ruled answer with a discriminating control, and no skip / todo / xit is added. Spot-checked across the categories:

No subject was weakened, skipped or deleted to reach green.

bootStack's docs — updated to the shape that remains. The orgContext docblock now says ASSERT (the vacuity guard), not switch; the "pure single-tenant (no org, no scoping)" comment at the AuthPlugin registration is replaced by the ADR-0131 D3/D11 statement; the autoDefaultOrganization: !!opts.orgContext pin is gone; no BootOptions key is added or removed; the multiTenant: 'posture-only' incompatibility is kept. harness.org-context.test.ts pins the default boot as the production shape, owner membership included, and the body's reverse verification shows that case reads the changed line.

② Semver level

  • Pre mode is ON at this merge base. a87d8be299 (.changeset/pre.json, mode pre, tag next) is an ancestor of 9f0de32a03. Two card statements are therefore stale premises on this head, not false sentences in the artifacts: the claim 6049583616 ("Changesets pre mode is not in yet, per triage 6037983378", written 00:25Z) and the round-1 report (".changeset/pre.json is absent on origin/main ec8f37c890", a base at 02:59Z — true then, pre entry landed 07:03Z). None of the five changesets and nothing in the PR body says pre mode is absent; each breaking changeset grades minor "under the repo's launch-window convention for breaking changes", which is the convention check-changeset-no-major.mjs's header states as in force until GA — pre mode lifts that guard's refusal of major (its RC exemption) but mandates nothing.
  • The right level for a BREAKING narrowing on the v18 pre-release line. By the repo's rule: (narrowing) is BREAKING and takes at least minor with the BREAKING banner and an ADR-0087 disposition; major is admissible in pre mode, not required. By precedent since pre entry: ten ! changesets landed; eight accept-set or runtime-interface narrowings graded minor under the same sentence (a3bcbcf3ca, 9ad3d2470e, 79c35d45e2, 81bd9fa6fe, 7518ee397d, ffb31fca3c, 73a0a6bf1d, d1dbe70ebd), and the two major grades (0e9e7b7066, 326a90a4a3) were authorable spec-export removals with registered ADR-0087 entries — a different class from this PR, which removes no authorable key or export. The fixed group already sits at 18.0.0-next.N from the opening marker, so neither grade moves a version on this line. minor is right for this class; the sentence is true.
  • The changesets match the diff. objectql, metadata-protocol, plugin-auth and verify: minor, Clause-②: yes (narrowing), a BREAKING banner, exactly one ADR-0087 disposition marker each (adr-0087: not-required (no-migration-prescription), in the HTML comment the gate reads), and a remedy. runtime: patch for the order-if-present declaration (no declaration change; a composition without the auth plugin is unaffected). The five packages the diff publishes are the five the changesets name; nothing published is left ungraded.
  • Clause-②: yes (narrowing) — right, for the reasons in ①.

③ Boundary flags

Implemented-by: claude/issue-15195-default-org-load-bearing
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 16:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 34dba5a Oct 8, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15195-default-org-load-bearing branch October 8, 2026 16:58
This was referenced Oct 8, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ore the settings engine binds (objectstack-ai#22257) (objectstack-ai#22312)

Fixes objectstack-ai#22257
Clause-②: no

## What was wrong

On `examples/app-showcase`, `os dev --seed-admin --fresh` logged one
`[SettingsService] Pre-bind READ of namespace 'auth'` on every boot.
Re-measured on `origin/main` `79c35d45` (after PR objectstack-ai#22295): 1 line, in
the boot-diagnostics block.

**The reader.** A temporary stack capture in the built
`reportPreBindRead` (applied to `service-settings/dist/index.js` and
reverted; the sha256 matched before and after, and the marker count
after the revert was 0) named it:

`SettingsService.getNamespace` from `AuthPlugin.bindAuthSettings`, then
`applySettings` (`auth-plugin.ts:1535` at `79c35d45`), from
`AuthPlugin.ensureAuthSettingsBound`, from `runBackfill` (`:1235` /
`:1244`), from the kernel's `trigger`.

The handler is the `app:seeded` hook registered in `AuthPlugin.start()`
(`:1298`), which calls `runBackfill('app:seeded')`. It is the ADR-0093
D6 one-time membership backfill.

**The phase.** A debug-level boot puts the read in Phase 2 (start). It
comes after `[Seeder] Seed loading complete` (132 rows,
`plugin.app.com.example.showcase`) and before `Triggering kernel:ready
hook`. `AppPlugin.start()` emits `app:seeded` when its inline seed
lands. On `os dev`, the auth plugin started first, so its handler ran
during Phase 2. `SettingsServicePlugin` binds the engine in its
`kernel:ready` hook (`settings-service-plugin.ts:221`), which is later.

The `optionalDependencies: ['com.objectstack.service.settings']` edge on
`AuthPlugin` orders only `kernel:ready` HOOKS. An event fired during
Phase 2 is outside it.

**The consequence.** `ensureAuthSettingsBound` is memoized, so the auth
binding was computed from the manifest defaults. In the debug log,
`Auth: bound to settings namespace=auth` appears at 14:18:11.661, before
`kernel:ready` at 11.672. The persisted rows were re-applied later only
through a fire-and-forget subscription callback. The one-time pass's
first run read the default policy `auto`.

**Why `check:settings-bind-window` missed it.** The gate walks `init()`
/ `start()` bodies and `kernel:ready` handlers only (`READY_HOOK`). It
reached this same read through the `kernel:ready` registration of
`runBackfill`, and classified it `declared`. The `app:seeded`
registration fires during Phase 2, from another plugin's `start()`, and
is not in the gate's population. This is reported as a blind spot below;
this PR adds no gate.

## The fix (`packages/plugins/plugin-auth/src/auth-plugin.ts`)

The one-time pass is now armed by its own `kernel:ready` hook. Any
trigger before that does nothing: `app:seeded` during Phase 2, or
`default-org-created` from the bootstrap middleware. The `kernel:ready`
pass still runs afterwards, after the settings bind, and scans every row
such a trigger was about. Triggers after `kernel:ready` behave exactly
as before. That includes an over-budget seed that settles in the
background (the objectstack-ai#2996 path).

- No change to which settings are read, only to when.
- No change to the reporter's level or text.
- No `packages/spec` change.
- The comment above `ensureAuthSettingsBound` in `runBackfill` was
stale: it said "registered in `init()`". It is corrected.

## Boot, before and after

| | `Pre-bind READ` lines | `Auth: bound to settings namespace=auth` |
|---|---|---|
| `79c35d45` (base) | 1 (`auth`) | Phase 2, before the `kernel:ready`
trigger |
| `9b7ac6cf` (fix; plugin-auth dist rebuilt, `backfillArmed` grep 3 in
`dist/index.mjs`) | 0 | after the `kernel:ready` trigger (16.538 vs
16.381) |

In both boots the D6 pass still records `adr-0093-membership-backfill`
once. The other boot-diagnostic lines are unchanged: `sys_migration`
UNIQUE and `[sharing-rule]`. See the acceptance notes.

## Pins


`packages/plugins/plugin-auth/src/auth-settings-seeded-boot.pin.test.ts`
composes:

- a real `ObjectKernel`
- ObjectQL on in-memory SQLite
- the REAL `SettingsServicePlugin`
- the real `AuthPlugin`, used before the settings plugin (the `os serve`
order)
- an app plugin in `AppPlugin`'s place: it writes the persisted
`auth.membership_policy = invite-only` row, then emits `app:seeded` from
its `start()`.

The dogfood harness cannot compose this. `bootStack` registers
`AppPlugin` before `AuthPlugin`, so `app:seeded` fires before auth
registers its handler.

The main case asserts four things:

1. The window is real: at `app:seeded`, the settings engine is unbound.
2. No `Pre-bind READ` is reported.
3. The auth binding's first `getNamespace('auth')` answers `{ value:
'invite-only', source: 'global' }`.
4. Every run of the one-time pass gets `policy: 'invite-only'`.

A control case forces a pre-bind read of `auth` in the same composition
and expects exactly one report.

`@objectstack/service-settings` is added as a devDependency of
plugin-auth. It is aliased to `src/` in `vitest.config.ts` (anchored,
like the three existing entries), so `KNOWN_UNALIASED_TEST_IMPORTS` is
unchanged. The lockfile gains only that importer entry.

**Control.** `settings-prebind-read-warning.test.ts` passes 9/9
unchanged. It still fires on a forced pre-bind read.

**Ablation**, via `scripts/ablation-replace.mjs` from committed
`d907051b`, with an EXIT/INT/TERM restore and the restore proven by blob
== HEAD and an empty `git diff HEAD`. `AuthPlugin` is imported
relatively, so `src/` is what runs and no dist rebuild was involved.

- Leg 1: delete `if (!backfillArmed) return backfillChain;` (anchor 1 to
0, blob `d559d607` to `83e4649d`). The main case goes RED at assertion
2: one `Pre-bind READ of namespace 'auth'`. The control stays green.
Restored to `d559d607`.
- Leg 2: the same deletion held, plus assertion 2 removed from the test.
The main case goes RED at assertion 3: the first `auth` read answered `{
value: 'auto', source: 'default' }`. Both files restored (blob == HEAD).

## Verification (the gate union was run on `b67e95f1`)

- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2`: 129 files, 2639 passed, 10 skipped, exit 0. This ran on
`d907051b`. The merge after it brought only `docs/adr/0096`.
- `pnpm --filter @objectstack/plugin-auth run typecheck`: exit 0.
`check:test-typecheck` is OK, and the new file is in the
`tsconfig.test.json` program (`--listFiles`: 1).
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at
`b67e95f1` derived 79 commands, and all 79 exited 0. `--ran`: 79
derived, 79 run, 0 NOT-MEASURED, 0 UNRUN.
- `pnpm check:settings-bind-window`: `4 declared / 0 self / 1
structurally upstream / 0 ledgered (73 plugin unit(s) scanned)`.
- Selected verdict lines:
- `check-test-source-alias OK — 73 packages with tests scanned; 60
registered`
- `check:workspace-manifest-cycles OK: 80 workspace package(s), 515
workspace: edge(s)`
  - `check-nul-bytes: OK`
  - `check:undeclared-dep-imports` passed
  - `check-adr-0087-registration: 1 non-breaking changeset(s) seen`
- eslint, narrowed to the 3 changed TS files: 0 errors and 0 warnings in
`--format json`. Each file has a non-empty rule set under
`--print-config`. `eslint.config.mjs` enables no type-aware linting, so
this diff cannot move a verdict on an untouched file. The repo-wide
`pnpm lint` is left to CI.
- NOT MEASURED: CI's Test Core, Dogfood, Build Core and the type-check
lanes. These are CI-owned.

## Acceptance notes

- **Gate blind spot (not fixed; no gate added).**
`check:settings-bind-window` cannot see two things:
- a settings read reached through a hook other than `kernel:ready` that
fires during Phase 2 (`app:seeded`, emitted from `AppPlugin.start()`);
- a read reached through a data-middleware or callback closure fired by
a Phase-2 write.

Its header says "Everything that runs before that bind hook is the
window." Its population is narrower than that window.
- **Landing order with PR objectstack-ai#22186.** That PR makes `AppPlugin` declare
`com.objectstack.auth` as an optional dependency, and creates the
Default Organization in `AuthPlugin.start()`. Without this fix, the
Phase-2 `app:seeded` pass would then have a target and would DECIDE the
one-time backfill under the manifest-default policy. This fix arms the
pass at `kernel:ready`, so it holds under either landing order. The two
diffs touch different regions of `auth-plugin.ts`: theirs is around
`:725` and `:1168`, this one is `:1232` to `:1316`.
- **Unchanged boot line.** `WARN Insert operation failed
{"object":"sys_migration", … UNIQUE constraint failed:
sys_migration.id}` appears on the fresh showcase boot both before and
after this change. It comes right after
`adr-0093-default-org-owner-bind` is recorded. Root cause is NOT
MEASURED; it is reported to the seat.
- **Merge state.** `origin/main` was merged at `799eb000`. `main` has
since moved 4 commits. None touches plugin-auth or service-settings;
plugin-security's strict mode is the nearest, and this composition
mounts no `SecurityPlugin`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ogs no refused sys_migration insert (objectstack-ai#22336)

Fixes objectstack-ai#22099
Clause-②: no

The one-time owner-bind gate (`createEnsureDefaultOrganizationOnce`,
`packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts`) now
marks its decision in flight synchronously, before its first `await`. A
call that finds the decision in flight runs `ensure(ql, { bindOwner:
false })` and records nothing. The deciding call records its own outcome
once. A call that did not act (`no_admin`, a refused write) clears the
mark on the way out, so the next trigger still decides. This is the
direction triage ruled (`6042758404`, option A). There is no promise
chain, no ledger upsert or insert-if-absent, and no catch-all.

Triage folded objectstack-ai#22102 into this card as a
duplicate (same row, same mechanism); objectstack-ai#22102 remains open, and its
disposition is the seat's.

## Re-measured on the landed shape (`origin/main` `28bff18d`, after PR
objectstack-ai#22186)

The gate still re-enters itself. The Default Organization boot invariant
(ADR-0131 D3) changes which write re-enters it: the outer call now
PROMOTES the reconciler-written `member` row instead of inserting
`sys_member`.

The chain on a first boot, outermost first (probe stacks through
`bootStack`):
- `kernel:ready`, `security-plugin.ts:4701` `runBootstrap`,
`bootstrap-platform-admin.ts:1170` `promote`, `:407` insert
`sys_user_permission_set`;
- plugin-auth middleware `auth-plugin.ts:1255`, `runEnsure` `:1224`: the
OUTER call;
- `ensure-default-organization.ts:498`, `promoteReconciledMemberToOwner`
`:379`, `ql.update('sys_member', { role: 'owner' })`;
- security-plugin middleware `security-plugin.ts:5145`,
`reconcileOrgAdminGrant` `auto-org-admin-grant.ts:777`, `:238` insert
`sys_user_permission_set`;
- plugin-auth middleware `auth-plugin.ts:1255` again: the INNER call. It
records `{ outcome: 'admin-already-member' }` first. The outer call's `{
outcome: 'promoted', organizationId }` is then refused by the primary
key.

| boot (CLI `os serve`, `examples/app-crm`, `NODE_ENV=development`) |
`sys_migration` failed-insert lines, before | after |
|---|---|---|
| `:memory:` | 1 | 0 |
| file SQLite, first boot | 1 | 0 |
| same file, second boot | 0 | 0 |

| `bootStack` (dogfood harness, empty app) |
`adr-0093-default-org-owner-bind` insert attempts, before | after |
|---|---|---|
| `:memory:` | 2 (second refused) | 1 |
| file, first boot | 2 (second refused) | 1 |
| same file, second boot | 0 | 0 |

Persisted owner-bind `details` (file DB, read back):
- before: `{"outcome":"admin-already-member"}`, while `sys_member` held
the admin as `owner` of the one default organization;
- after:
`{"outcome":"promoted","organizationId":"org_muzty5e5bxpe2dtt"}`, which
is the organization of the admin's `owner` row.

Walled first boot (`bootStack`, `isolated`, `OrganizationsPlugin`
mounted), measured on `main`: ONE owner-bind insert,
`{"outcome":"bound","organizationId":…}` matching the operator's
`sys_member` row, and no warning. Under a wall the grant-insert arm of
`isDefaultOrganizationBootstrapTrigger` is retired, so this chain does
not re-enter the walled wiring today. The walled wiring calls the same
gate and gets the same rule.

## Pins

- `packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts`:
the gate over a fake engine whose `sys_migration` insert refuses a
duplicate id and whose `sys_member` writes re-enter the gate.
- a fresh bind records `bound` once, with the org `sys_member` points
at;
  - the promotion (the landed boot shape) records `promoted` once;
  - a concurrent trigger binds nobody: one owner row, one record;
  - a call that did not act clears the mark;
- CONTROL: a real ledger insert failure still reaches
`recordLedgerDecision`'s `error` branch.
-
`packages/plugins/organizations/src/walled-default-org-owner-bind-in-flight.pin.test.ts`
sits beside `walled-default-org-self-registrant.pin.test.ts`. A
`sys_user` `email_verified` update reaches `OrganizationsPlugin`'s own
bootstrap middleware while the bind is in flight. The ledger is written
once, `bound`, with the org `sys_member` points at.
-
`packages/qa/dogfood/test/sys-migration-boot-ledger-once.dogfood.test.ts`
is the family's enumeration pin. It boots a fresh database twice over
one file through `bootStack`. The ids come from the table's own rows,
and each one must have been inserted exactly once. No line at `WARN` or
above may name `sys_migration`. The owner-bind row must read the
deciding call's outcome with its owner's organization. The control
requires the capture to have parsed an `INFO` line naming the ledger.
- **Deviation from the ruled pin text:** triage's pin says the persisted
`details` read `bound`. On the landed full boot the accurate outcome is
`promoted` (ADR-0131 D3 binds the admin as `member` before the gate
runs). So the dogfood pin asserts `promoted`, and the `bound` arm is
pinned at the unit and walled layers, where the gate inserts the owner
row.

## Ablation (at `d286091a08`, fix committed first; delete the
synchronous mark)

The mark (`deciding = true;`) was deleted with
`scripts/ablation-replace.mjs`: anchor 1 → 0, blob `27c001b5` →
`635eb0e8`. plugin-auth was rebuilt (exit 0), and
`ablation-dist-preflight --absent 'deciding = true'` passed.
- plugin-auth pin: 3 failed, 2 passed. The re-entry, promotion and
concurrent cases are red; the clears-the-mark case and the CONTROL are
green, as predicted.
- walled pin: 1 failed. `expected [ …(2) ] to have a length of 1 but got
2`.
- dogfood pin: 3 failed, 1 passed. `adr-0093-default-org-owner-bind: 2`
against 1; `boot 1: expected [ Array(1) ] to deeply equal []` (the
warning); `{ outcome: 'admin-already-member' }` (the lost update). The
control was green.

The restore was proved: blob `27c001b5` matches HEAD and `git diff HEAD`
is empty. plugin-auth was rebuilt and the preflight found `deciding =
true` in dist again. `git status --porcelain` was empty.

A first ablation leg deleted only the `|| deciding` read. Its JS reached
dist, but the build exited 1 at the DTS step (TS6133: `deciding` was
never read). That leg is VOIDED as a reading, and the leg above replaces
it. Its colours were the same.

## Verification

The package runs below are at `33520be606`. The final commit
`82ae109f5e` changed only the plugin-auth pin's `findOne` double and the
generated `scripts/engine-double-contract.pinned.json`, and the runs it
can move were repeated there. The gate union ran at `82ae109f5e`.
- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2`: 130 files, 2662 passed, 10 skipped. At `82ae109f5e` the
new file re-ran 5/5 and `check:test-typecheck` re-ran OK.
- `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0.
`check:test-typecheck` held 10 files / 94 errors, unchanged.
- `pnpm --filter @objectstack/organizations exec vitest run
--maxWorkers=2`: 12 files, 152 passed. Its `typecheck` exited 0.
- `pnpm --filter @objectstack/dogfood typecheck`: exit 0. The
enumeration pin: 4/4.
- The full dogfood suite is NOT run locally; it is declared to CI's
`Dogfood Regression Gate`. Only the new file ran here. No importer of
`plugin-auth` owes a test: the diff changes a function body and a module
comment, and no exported declaration.
- ① `turbo run build --filter='@objectstack/plugin-auth^...'
--filter='@objectstack/organizations^...'
--filter='@objectstack/dogfood^...' --concurrency=1`: 63/63.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 76 commands from this branch's
change set at `82ae109f5e`. All 76 ran there and exited 0. `--ran`
reconciled them: `76 derived famil(ies) accounted for — 76 run, 0
NOT-MEASURED`. Sample verdict lines:
- `check-engine-double-contract: OK — 986 pinned, 129 in the DEBT
ledger, 3 exempt.`
- `check-nul-bytes: OK (scanned 10308 text file(s) …; no raw ASCII
control bytes).`
- `check-test-source-alias OK — 73 packages with tests scanned; 60
registered as still resolving a workspace dep through dist/ …`
- `OK: 30 package(s) read outside themselves, all declared …`
(`check:cross-package-test-inputs`).
- The first pass at `33520be606` had two non-zero lines.
`check:engine-double-contract` was red: the new findOne double was not
routed through `assertEngineFindOnePredicate`, and the ledger lacked the
new rows. Repaired in `82ae109f5e`. `check:dual-build-cjs-loads` printed
`PREREQUISITE NOT MET` (8 packages had no dist). It was NOT MEASURED
then, and it exited 0 after those dists were restored from the turbo
cache.
- eslint, narrowed (CI owns `pnpm lint`): 4 files, 0 errors and 0
warnings by `--format json`. Each file is matched by `eslint.config.mjs`
(`--print-config`), and none is ignored. `eslint.config.mjs` enables no
type-aware linting (no `parserOptions.project`), so this diff cannot
move the verdict on any untouched file.

## Acceptance notes

- `scripts/engine-double-contract.pinned.json` gains three generated
rows (`--write`, grow-only coverage ledger) for the two new pinned
doubles. This file is outside the claim's declared surface, and the gate
requires it for any new pinned double.
- Read-only inference, not reproduced, and no card filed (carrier:
none). On a kernel with NO ledger, an in-flight caller runs `ensure`
with `bindOwner: false` and may still CREATE the default organization
while the deciding call waits. The decider then sees an existing
organization under `bindOnlyOnCreate` and binds nobody.
`ensureDefaultOrganization`'s org creation was already not
concurrency-safe (two concurrent calls can both insert one). Every
served kernel composes the ledger (`PlatformObjectsPlugin`), and
`single` creates the organization at boot.
- Not measured: a production first sign-up (no dev admin). The gate's
rule does not depend on which trigger re-enters it.
- Rows already written as `admin-already-member` are not rewritten.
Nothing reads `details`: `readLedgerDecision` answers existence only.

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

---------

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