Skip to content

fix(cli,runtime): the 17 → 18 ownership ceremony leaves no row without an organization on a single-posture database, its prescribed boot writes nothing before the guard refuses, and an unverified ceremony is not served - #24325

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-24282-ceremony-null-org-guard
Oct 18, 2026

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #24282

Clause-②: yes (widening)

Basis for the Clause-② line, as the seat re-declared it on the claim: one new entry export, ensureDefaultOrganizationExists from @objectstack/plugin-auth (the package entry lists 221 names on main and 222 here, the list is below). The @objectstack/runtime entry is byte-identical to main (src/index.ts unchanged, 637 names before and after), and @objectstack/cli exports nothing new. No published schema and no REST answer moves.

This is leg A of #24282: the organization-ownership ceremony and the v18 boot guard. Leg B, where the seed loader refuses an organization-scoped twin (packages/metadata-protocol), is tracked on #24301, and #24301 is not addressed here. Repairing a hosted database's existing twins is an operator's data repair. It is not this PR.

What changes

  1. On a single-posture database, the ceremony leaves no NULL-organization row (packages/cli). On a database that holds no organization at all, with rows that no anchor names, os migrate organization-ownership now plans those rows as the Default Organization's. The plan says so in a warning (defaultOrganizationCreatedByApply: true). --apply then creates the Default Organization before its first write and gives it every such row. A run that cannot create it blocks at attribution and says why. It no longer checkpoints attribution done at zero rows and then certifies.
    • The route comes from ADR-0131, not from a choice made here. D3 says the Default Organization "exists before the first write that needs an owner", and D10 fate 3 says the attributed rows take their owner "under single, the Default Organization". The ceremony's attribution is a write that needs an owner, so the owner must exist before it runs.
    • The other route the card offered was "do not checkpoint attribution done at zero". On its own, that route deadlocks once the boot writes nothing before it refuses (point 3): nothing would ever create the owner.
    • Nothing is created under a walled posture, or when the database already holds organizations.
    • The ceremony names the Default Organization the way the boot does (patch round 1, the seat's ruling). Under single, the Default Organization is the organization with slug = 'default', or else the sole organization whatever its slug. That is the find half of plugin-auth's invariant and the tenancy service's defaultOrgId(), and ADR-0093 D3 states it: "the bootstrap org (stable slug='default'), or the sole org row if a deployment renamed it". ADR-0131 has no text that limits it to the slug.
    • Before this round, the plan required the slug. A 17.x single database whose only organization had another slug kept its unanchored rows NULL, failed verification, and with point 4 its boot would have been refused, although v18 derives every owner from that organization.
    • Several organizations with none default still name none, and their rows are reported. The plan file's defaultOrganization.slug is now that organization's own slug. The plan file is the command's output, not a published schema.
    • The organization is created through plugin-auth's boot invariant, ensureDefaultOrganizationExists, which is the function AuthPlugin.start() and the identity-objects plugin call. So the row is the one a single-posture boot would have created, and it has one producer.
  2. A checkpointed attribution runs again when the plan finds an owner for rows that are still NULL (packages/cli). This is the card's step 5. The step writes only where organization_id IS NULL, so it never derives an owner a second time (D10 item 3). It covers D10 item 4's "resolves them and re-runs". It also completes a database that an earlier release's ceremony left with attribution checkpointed at zero.
  3. The boot the ceremony prescribes writes nothing before its guard refuses (packages/runtime, packages/plugins/plugin-auth). OrganizationOwnershipBootGuardPlugin now judges in its own start(), not at kernel:ready.
    • It starts after the engine, whose start() runs the additive schema sync that the rewrite step's "boot this release once" relies on, and after the platform objects.
    • It starts before every plugin that writes rows at start. AppPlugin (the inline seed), AuthPlugin and the identity-objects plugin (the Default Organization) now list it in optionalDependencies, so the order is declared and does not depend on registration order. os serve registers it before them as well.
    • Each writer spells the name as the string literal 'com.objectstack.organization-ownership-boot-guard', because check:init-service-contract reads optionalDependencies only as string literals. An identifier element there made the guard read AppPlugin's whole array as undeclared, which is CI's red on a91dad2d8, fixed in round 1.
    • A start() that throws is reported by the kernel as its own error, with the refusal as its cause. os serve and os dev now read the refusal from that chain, so os dev's automatic upgrade and the printed refusal text are unchanged.
  4. The guard does not serve a ceremony that did not verify (packages/runtime). Once no table is left in the v17 shape, a boot whose adr-0131-organization-ownership marker exists but is not verified is refused with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED. That state is the one where the post-check found rows the ceremony did not carry, and the refusal names the re-run. D10 item 5 refuses a database the ceremony has not completed, and verified_at is the ceremony's completion marker. A database with no marker (a fresh one) boots as before, and a marker that cannot be read is refused rather than passed.
    • A boot with no storage (os serve with no OS_DATABASE_URL and no driver, so no driver serves sys_migration) has no ledger to judge, and it boots as before. The guard checks this the same way its column probe skips an object no driver serves.
    • Before round 1, the engine's "no driver" refusal of that read was taken as an unreadable marker, which is CI's red in Test Core 3/6 and 4/6 on a91dad2d8 (four storage-less os serve integration tests). Fixed in round 1.

The measured path, before and after

This is the card's path, run with the real commands (os serve with OS_MIGRATE_AND_EXIT=1 for the boots, os migrate organization-ownership for the ceremony) on one SQLite file.

The file was built from a served boot of this release. It was then put into the 17.x shape: no organization, every organization_id NULL, sys_job still carrying its retired column, and sys_user_permission_set without permission_set, so the rewrite step asks for a boot. Its seed holds 5 rows of an application object, plus 5 NULL rows in sys_activity, sys_setting and sys_setting_audit.

step main (d537a318b) this branch (final head)
plan 0 organizations, 10 rows unattributable, no warning 0 rows unattributable, defaultOrganizationCreatedByApply: true, and the warning says --apply creates the Default Organization and gives it 10 rows
--apply #1 attribution done with rowsAttributed: 0, rewrite blocked ("Boot this release once…"), exit 1 Default Organization created, attribution done with rowsAttributed: 10, rewrite blocked, exit 1
the prescribed boot refused, after writing sys_organization 0 → 1 and stamping all 5 seeded rows with the new organization refused; no table's row count moved
--apply #2 attribution checkpointed, complete: true, blocking: 5 (sys_activity 2, sys_setting 1, sys_setting_audit 2), marker applied with verified_at NULL, exit 1 complete: true, blocking: 0, marker verified, exit 0
the next boot served (exit 0) on the unverified marker served (exit 0) on the verified marker; the 5 seeded rows keep their one owner

The reproduction ran on a91dad2d8, and round 1 does not change this path (the database holds no organization). The reproduction is not a permanent test. It is pinned instead by the command-level test below.

Public surface

The @objectstack/plugin-auth entry adds exactly one export and removes none. Its full signature, from the built dist/index.d.ts, with each generic spelled out in words so the platform's body sanitizer leaves it intact:

declare function ensureDefaultOrganizationExists(ql: any, options?: { logger?: InvariantLogger })
  : Promise of DefaultOrganizationInvariantResult
interface DefaultOrganizationInvariantResult { organizationId?: string; created: boolean; ambiguous?: { organizationCount: number } }
interface InvariantLogger { info: (message: string, meta?: Record of string to any) returning void }

The two interfaces are reached through the signature. They are not entry exports. Apart from that export line and these three declarations, the dist/index.d.ts with comments stripped is line-for-line unchanged.

Entry exports after this change, sorted (222 names). Before is the same list without ensureDefaultOrganizationExists (221 names):

ACTIVATION_LEDGER_STANDING_KEYS, AUTH_ACCOUNT_CONFIG, AUTH_ADMIN_SESSION_FIELDS, AUTH_ADMIN_USER_FIELDS, AUTH_DEVICE_CODE_SCHEMA, AUTH_INVITATION_SCHEMA, AUTH_JWKS_SCHEMA, AUTH_MEMBER_SCHEMA, AUTH_MODEL_TO_PROTOCOL, AUTH_OAUTH_ACCESS_TOKEN_SCHEMA, AUTH_OAUTH_APPLICATION_SCHEMA, AUTH_OAUTH_CLIENT_ASSERTION_SCHEMA, AUTH_OAUTH_CLIENT_RESOURCE_SCHEMA, AUTH_OAUTH_CLIENT_SCHEMA, AUTH_OAUTH_CONSENT_SCHEMA, AUTH_OAUTH_REFRESH_TOKEN_SCHEMA, AUTH_OAUTH_RESOURCE_SCHEMA, AUTH_ORGANIZATION_SCHEMA, AUTH_ORG_SESSION_FIELDS, AUTH_PHONE_NUMBER_USER_FIELDS, AUTH_SESSION_CONFIG, AUTH_TEAM_MEMBER_SCHEMA, AUTH_TEAM_SCHEMA, AUTH_TWO_FACTOR_SCHEMA, AUTH_TWO_FACTOR_USER_FIELDS, AUTH_USER_CONFIG, AUTH_VERIFICATION_CONFIG, AccountIdentityCollision, AccountIdentityPreflightOptions, AccountIdentityPreflightRefusal, AccountIdentityPreflightReport, AccountIdentityReadEngine, AdminActor, AdminBanEndpointDeps, AdminCreateCapableApi, AdminUserDataEngine, AdminUserEndpointDeps, AdoptMembershipOptions, AuthActorResolver, AuthBanContextLike, AuthConfig, AuthContextLike, AuthEventAuditSurface, AuthManager, AuthManagerOptions, AuthPlugin, AuthPluginConfig, AuthPluginOptions, AuthProviderConfig, AuthRequestHandler, AuthSessionAuditEventInput, BackfillMembershipsResult, CanonicalizeStoredMemberRolesOptions, CanonicalizeStoredMemberRolesResult, CounterStore, CredentialAccountAdapter, DEFAULT_ACCOUNT_SCAN_MAX, DEFAULT_AUTH_BASE_PATH, EndedMembership, EndpointResult, EnsureDefaultOrganizationOnceOptions, EnsureDefaultOrganizationOptions, EnsureDefaultOrganizationResult, FixedWindowCount, GRANT_STANDING_KEYS, IDENTITY_OBJECTS_PLUGIN_NAME, IMPORT_USERS_MAX_ROWS, INTERACTIVE_REVOKE_REASON, IdentityImportDeps, IdentityImportEngine, IdentityImportRowResult, IdentityObjectsPlugin, IdentityObjectsPluginOptions, IdentityWriteGuardOptions, ImportManagerOutcome, ImportPasswordPolicy, InProcessCounterStore, InvitationPlacementServiceLike, LAST_LOCAL_CREDENTIAL_CODE, LAST_LOCAL_CREDENTIAL_MESSAGE, LastAdminGuardEngine, LastAdminGuardOptions, LazyCacheRateLimitStorageOptions, LazyCounterStoreOptions, MAX_COOLDOWN_SECONDS, MEMBERSHIP_ENDED_REVOKE_REASON, MEMBERSHIP_POLICIES, MEMBER_OBJECT, MEMBER_STANDING_KEYS, METADATA_ACTIVATION, MemberRoleCanonicalizationOptions, MemberRoleSpellingCensusEntry, MembershipAdoptionDecision, MembershipAdoptionEngine, MembershipAdoptionVerdict, MembershipEndOutcome, MembershipEndedSessionEngine, MembershipEndedSessionOptions, MembershipPolicy, NORMALISED_IDENTIFIER_FIELDS, ObjectQLAdapterFactoryOptions, OtpGuardStorage, OtpSendDecision, OtpSendGuard, OtpSendGuardOptions, PLACEHOLDER_EMAIL_DOMAIN, PLATFORM_ADMIN_REFUSAL_MESSAGES, PlatformAdminActor, PlatformAdminRefusal, PlatformAdminVerdict, ReconcileMembershipDeps, ReconcileOutcome, RegisterSsoFormResult, RepointGuardEngine, ResendVerificationEmailResult, SESSION_ERASURE_PATHS, SIGN_OUT_PATH, STANDING_KEYS_BY_TABLE, STANDING_KEY_EXCLUSIONS, SUPPORTED_WHERE_OPERATORS, SYS_ACCOUNT_OBJECT, SYS_USER_IMPORT_UPDATE_FIELDS, SYS_USER_PROFILE_EDIT_FIELDS, SecondaryStorageLike, SessionTombstonePatch, SetInitialPasswordResult, SetPasswordCapableApi, TenancyPosture, TenancyService, TenancyServiceDeps, USER_STANDING_KEYS, adoptExistingMembership, assertNoAccountIdentityCollisions, assertOtpCooldownSeconds, assertScimAdminCoherence, authEmailLocaleFromRequest, authSystemWriteContext, backfillMemberships, buildAdminPluginSchema, buildDeviceAuthorizationPluginSchema, buildJwtPluginSchema, buildOauthProviderPluginSchema, buildOidcProviderPluginSchema, buildOrganizationPluginSchema, buildPhoneNumberPluginSchema, buildTwoFactorPluginSchema, cacheSecondaryStorage, canonicalMemberRole, canonicalizeStoredMemberRoles, counterStoreFromKv, createEnsureDefaultOrganizationOnce, createIdentityObjectsPlugin, createLazyCacheRateLimitStorage, createLazyCounterStore, createObjectQLAdapter, createObjectQLAdapterFactory, createTenancyService, currentAuthEndpointPath, decideAdoptedRole, emitAuthSessionAuditEvent, endSessionClaimsForEndedMembership, ensureDefaultOrganization, ensureDefaultOrganizationExists, filterRevokedSessionRows, formatAccountIdentityPreflightReport, generatePlaceholderEmail, generateTemporaryPassword, getManagedUpdateWhitelist, hideRevokedSessionRow, incrementFixedWindow, ipMatchesRange, isCanonicalMemberRole, isDefaultOrganizationBootstrapTrigger, isKnownMembershipRole, isLastLocalCredentialHolder, isLikelyEmail, isMembershipPolicy, isOAuthEligibleBaseUrl, isPlaceholderEmail, isPlatformAdminUser, isRevokedSessionRow, judgeHeldSession, judgePlatformAdmin, loginEventFor, logoutEventFor, mapAuthApiError, normalizeAuthEmailLocale, normalizePhoneNumber, probeAccountIdentityCollisions, readInvitationPlacementIntent, readMcpServerEnabledEnv, reconcileMembership, reconcileSessionDelete, refuseIssuerRepointWithLiveBindings, registerIdentityWriteGuard, registerLastAdminGuard, registerManagedUpdateWhitelist, registerMemberRoleCanonicalization, registerMembershipEndedSessionTrigger, resolveAttributedUserId, resolveDcrEnabled, resolveDefaultOrgId, resolveOidcProviderEnabled, resolveProtocolName, resolveScimEnabled, runAdminBanUser, runAdminCreateUser, runAdminImportUsers, runAdminSetUserPassword, runAdminUnbanUser, runAttributedToUser, runRegisterSamlProviderFromForm, runRegisterSsoProviderFromForm, runRequestDomainVerification, runResendVerificationEmail, runSetInitialPassword, runVerifyDomain, runWithAuthActorScope, setAuthActorResolver, withSystemContext, withSystemReadContext, withValidationErrorMapping

@objectstack/runtime's entry is unchanged. Its declarations differ in one line, which is not a type change: readonly name = "com.objectstack.organization-ownership-boot-guard" becomes readonly name: "com.objectstack.organization-ownership-boot-guard", the same literal type, now taken from a package-internal constant that AppPlugin reads. The one behaviour a host can observe is that a host composing the guard now sees the kernel's start-failure error with the refusal as its cause. The changeset says so, with the one-line read.

Why the export, and not a narrower route. default-organization-invariant.ts kept the invariant internal so that no host composing a kernel has to remember to call it. That still holds: the module header now says so, and only the two plugins call it on a boot. The ceremony is not a host composing a kernel. It is the one caller that is not a boot, and the boot that would have created the organization is the one the guard now refuses before any plugin writes. The routes that need no export are either a second producer of the Default Organization row (an engine insert in the CLI) or calling IdentityObjectsPlugin.start() by hand outside a kernel. The first forks the invariant and the second is a workaround.

Files outside this lane

These files belong to domain:services (plugin-auth), and the seat declared them on the lane post:

  • packages/plugins/plugin-auth/src/index.ts
  • default-organization-invariant.ts (the header note)
  • auth-plugin.ts and identity-objects-plugin.ts (the guard in optionalDependencies, plus the note)
  • default-organization-guard-ordering.pin.test.ts

The changes there are the producer side of points 1 and 3.

packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts is updated to read the refusal from the cause chain.

Tests

  • packages/runtime
    • organization-ownership-boot-guard.kernel.test.ts boots a real kernel over SQLite. In the hostile slot, AppPlugin and plugin-auth's identity-objects plugin are use()d before the guard, and the refused file holds no organization and no seed row. AppPlugin alone gives the same result. Control: the same composition over a fresh file writes both. It also covers the unverified-marker refusal with no v17 column, plus its verified control.
    • In the same kernel test, a fresh database boots with the ledger registered and its read answering no row. That means the guard took the read and found the marker absent, not unreadable.
    • organization-ownership-boot-guard.test.ts covers the start-phase refusal registering no hook, the declarations, the unverified, unreadable and absent marker cases after the column drops, a boot with no storage, and the existing cases.
  • packages/plugins/plugin-auth
    • default-organization-guard-ordering.pin.test.ts shows that the declaration moves the order for AuthPlugin and IdentityObjectsPlugin in a hostile registry, that removing it reverts the order, and that it is order-if-present.
  • packages/cli
    • utils/organization-ownership-apply.test.ts covers creation before the first write, the marker verifying, a missing or failing producer blocking at attribution, and the controls (no row falls to the Default Organization, a walled posture, organizations present with none the Default Organization). It also covers the checkpointed attribution running again over the state an earlier run left.
    • One definition of the Default Organization (round 1, in utils/organization-ownership-apply.test.ts). Over the same SQLite fixtures, the plan's defaultOrganization and plugin-auth's ensureDefaultOrganizationExists name the same organization in each case:
      • default present beside another organization
      • a sole organization with another slug
      • two organizations with none default (both name none; the invariant answers ambiguous)
      • none at all (the plan says --apply creates it; the invariant creates it, and the next plan names it)
    • Also in round 1: a sole organization with another slug takes every unanchored row through --apply, and the marker verifies.
    • utils/organization-ownership-plan.integration.test.ts covers the plan's count and warning, the sole-organization case, and the walled control.
    • commands/migrate/organization-ownership.no-organization.integration.test.ts runs the card's path through the real command: plan, --apply, the guarded boot refused with every row count unchanged, --apply again verified, then the guarded boot starts with no duplicate.

Round-1 results on head eb9605968, through the shared verify lock:

  • runtime guard files: 3 files, 29/29 passed
  • cli: the ceremony family, plus the four os serve integration files CI found red on a91dad2d8 (serve-no-transport-announcements (both), serve-no-storage-boot-guard, serve-kernel-only-lifetime, serve-no-server-banner), 29 files: 275 passed, 1 skipped
  • typecheck for runtime and cli: exit 0. plugin-auth is unchanged this round.

Round 0, on a91dad2d8:

  • plugin-auth ordering pins: 13/13 passed
  • typecheck for runtime, plugin-auth and cli: exit 0

Full suites, on the round-0 pre-merge head:

  • runtime: 417 files, 6573 passed
  • plugin-auth: 160 files, 3118 passed
  • cli unit project: 308/310 files. The 2 remaining failed only because packages/cli/dist was not yet built (published-subpath-*.pin). They passed on the final head after the build.

Incoming main touched none of these packages' sources other than a runtime test and a dev alias.

The touched dogfood file (organization-ownership-boot-refusal-order.dogfood.test.ts, the showcase composition upgraded through the refusal) was run once on the final head: 4/4 passed. In that run the refused boot still adds the column the ceremony waits for, the ceremony verifies, and the next boot starts.

Ablations. Each one was mutated through scripts/ablation-replace.mjs, which verifies the mutation landed, and restored, with blob equal to HEAD and git diff HEAD empty:

  • guard back at kernel:ready: 4 red, including both "writes nothing" cases

  • AppPlugin without the guard declaration: 2 red (the declaration pin and the AppPlugin-alone case). The all-writers case stays green, because the identity-objects plugin's own declaration still pulls the guard ahead.

  • no marker read once the columns are gone: 3 red

  • attribution without creating the Default Organization: 3 red

  • the plan reporting instead of counting: 4 red

  • a checkpointed attribution never re-run: 1 red

  • round 1, the plan's Default Organization reverted to slug-only: 3 red (the one-definition pin's sole-organization case, the plan's sole-organization case, and the --apply sole-organization case)

  • round 1, the storage-less ledger check removed: 1 red

A first attempt at the AppPlugin leg was refused by the tool (a delete written as a replace) and was re-run as --delete.

Gates (round 1, head eb9605968).

  • node scripts/pm/dispatch-gates.mjs --commands derives 80 commands, and all 80 exit 0. check:dual-build-cjs-loads and check:i18n-coverage first exited 3 on missing dist/ and exited 0 after building what they named. --ran reconciliation: 80 derived, 80 run, 0 NOT-MEASURED.
  • Declared wide-population families, listed by dispatch-gates without --commands, whose population covers the changed packages:
    • check:init-service-contract exited 1 on a91dad2d8 (reproduced locally: packages/runtime/src/app-plugin.ts:710 — AppPlugin) and exits 0 on the fix.
    • check:live-db-isolation, check:optional-error-sink, check:resume-authority-declared, check:runner-env-posture, check:settings-bind-window, check:startup-registry-verdict, check:verify-stand-in and check:wildcard-fallthrough each exit 0.
    • check:meta-type-normalized reads packages/rest/src only, which this diff does not touch.
  • pnpm exec eslint --no-inline-config over the 18 changed source and test files exits 0.
  • Changed lines: +1242 / −115 over 19 files.

Acceptance notes


Generated by Claude Code

… ceremony; the ceremony creates the Default Organization

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…efusal, and the ceremony's Default Organization

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…the ceremony creates the Default Organization only when a row falls to it

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…e guard and the unverified-marker refusal

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
… boot guard ahead of them

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…s package-internal and hosts walk the refusal's cause chain; plugin-auth exports the invariant alone

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…holds the inline seed behind the guard

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…remony-null-org-guard

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
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 18, 2026
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/plugin-auth, @objectstack/runtime, touching 30 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/default-organization-invariant.ts, packages/plugins/plugin-auth/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.

22 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 ec749fbb44b5e6015d3f205c63dfa47cfb6ef445.

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/default-organization-invariant.ts, packages/plugins/plugin-auth/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: os serve (command, 34 pages)
  • 3 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 — 51 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 ec749fbb44b5e6015d3f205c63dfa47cfb6ef445 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json ec749fbb44b5e6015d3f205c63dfa47cfb6ef445

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — REWORK (patch round 1)

domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T12:37Z · on head a91dad2d8 · #24282 leg A (claim 6248532332, report 6249921017). This is the seat's own review at the default tier, owed for Clause-②: yes (widening) (@objectstack/plugin-auth exports ensureDefaultOrganizationExists), read against GitHub, not the report.

Accepted as it stands:

  • The guard judges in its own start(), ordered after the engine and com.objectstack.platform-objects. AppPlugin, AuthPlugin and the identity-objects plugin name it in optionalDependencies, so a refused boot writes no row. The refusal reaches os serve / os dev down the cause chain (serve.ts, depth 8).
  • With no table left in the v17 shape, an unverified marker refuses, and an absent marker boots (ADR-0131 D10 item 5).
  • The ceremony creates the Default Organization only under single on a database that holds none, through plugin-auth's boot invariant (D3: one producer of the row). The plan says so in a warning before the first write (the 2026-10-16 amendment).
  • A checkpointed attribution runs again only when the plan derives an owner for rows still NULL, and writes only where organization_id is NULL.
  • The widening is one entry export (221 → 222 names), and the minor changeset names it. The runtime entry is unchanged. The cross-lane declaration for plugin-auth is [PM seat] domain:services — 🟢 os-sales #6021 6249475347.
  • Size: 19 files, +1096 / −101. check-governed-merges --pr 24325: NOT governed, under the 3,000-line threshold.

Owed in this PR:

  1. CI is red: Lint · docs & code guards → Init-service declaration guard (job 117675069970). The guard reports packages/runtime/src/app-plugin.ts:710 (AppPlugin): "init() resolves getService('objectql') … and NOTHING declares that ordering."
    • Cause: the checker reads optionalDependencies only as an array of string literals (scripts/check-init-service-contract.mjs:163–:169, if (!ts.isStringLiteralLike(el)) return undefined). The new identifier element ORGANIZATION_OWNERSHIP_BOOT_GUARD_PLUGIN_NAME makes it read the whole array as undeclared, 'com.objectstack.engine.objectql' included.
    • Ask: spell the guard's name as a string literal in AppPlugin.optionalDependencies, as AuthPlugin and the identity-objects plugin already do. ⛔ The checker is not edited.
    • The family is a declared wide-population one, so dispatch-gates --commands does not list it. Run the declared-wide families that read the changed packages (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack over the changed paths, without --commands), pnpm check:init-service-contract among them, and cite their exit codes.
  2. The ceremony and the boot define the Default Organization differently. This PR adds a refusal for an unverified marker, which turns that difference into a refused boot.
    • The plan finds it by slug = 'default' only (packages/cli/src/utils/organization-ownership-plan.ts:1297–:1305).
    • The boot invariant this PR now exports takes the sole organization, whatever its slug, when none has slug = 'default' (default-organization-invariant.ts, the find half: "The organization a single install derives every owner from").
    • Consequence: a 17.x single database whose only organization has another slug keeps its no-anchor rows NULL through the ceremony. The post-check then leaves verified_at NULL, and with this PR the next boot is refused, although v18 would derive every owner from that organization. That is inside A1 as the card states it ("leaves no NULL-organization row on a single-posture database").
    • Ruling (the meta-rule for one operation with two implementations: the boot's governed producer wins): the plan names the Default Organization the way the invariant does. That means slug = 'default', else the sole organization. Several organizations with none default stays "no Default Organization", reported as today.
    • Hold the two definitions together with a pin over the same fixtures: default present, a sole organization with another slug, two with none default, and none at all. The plan file's defaultOrganization.slug stops being 'default'-only. Say so in the changeset if the plan's JSON shape is published.
    • Premise, falsifiable: the find half reads as quoted above, and no ADR-0131 text limits the Default Organization to slug = 'default'. If either fails, stop and report with the quote. ⛔ Do not pick a side silently.
    • Acceptance note 3 then leaves the PR's Acceptance notes.
  3. Measure; change code only if it fails. The guard now reads sys_migration on every served boot, and a throwing read refuses. Cite the test that shows a fresh database served by os serve reads the marker as absent, not unreadable, or add one beside the kernel test.

Out-of-scope notes, settled:

Before pushing: read this head's Test Core shards, which were still running at this stamp, and fold any red into the same round. The landing waits on the patch head and every check on it.


Generated by Claude Code

… check:init-service-contract reads optionalDependencies

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…ariant does — slug default, else the sole organization; pin both definitions over one fixture set; the guard's fresh-database marker read is pinned absent

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…rd reads the marker as absent when no driver serves sys_migration

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
… and imports the invariant statically

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

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T13:36Z · on head eb9605968 · #24282 leg A (claim 6248532332, reports 6249921017 and 6250537902). This is the seat's own review at the default tier. It is the review of record owed for Clause-②: yes (widening), read against GitHub, not the report.

  1. Shape:
  2. Premise, measured: the card's A1–A4 path was reproduced on d537a318b with the real commands on one SQLite file, then re-run on the head.
    • On d537a318b: 10 unattributable rows; attribution checkpointed done at 0; the refused boot created the organization and stamped seeded rows; verified_at stayed NULL; and the next boot served.
    • On the head: 10 rows attributed; the refused boot moved no row count; the ceremony verified with blocking 0; and the next boot served.
  3. Patch round 1 (6250003464), each item read on the diff a91dad2d8..eb9605968:
    • AppPlugin.optionalDependencies spells the guard's name as a literal. check:init-service-contract was reproduced red, then shown at exit 0. The checker is untouched.
    • The plan names the Default Organization as the boot invariant does: slug = 'default', else the sole organization; several with none default name none.
      • Its basis is ADR-0093 D3, quoted: "or the sole org row if a deployment renamed it". ADR-0131 contains no slug text.
      • A pin holds the plan and the invariant together over one fixture set. Reverting to slug-only turns 3 cases red.
    • A fresh database reads the marker as absent, and the kernel test asserts it.
    • The Test Core 3/6 and 4/6 reds on a91dad2d8 were folded into the round. Four storage-less os serve tests were refused because the engine's "No driver available for sys_migration" was read as an unreadable marker.
      • The fix: a ledger no driver serves is absent, as the column probe already skips an object no driver serves.
      • It widens no admission. absent boots only when no table is left in the v17 shape; with such a table, an absent marker still refuses. A store that serves the ledger and fails its read is still unreadable, so it refuses.
  4. Clause-② review of record (the widening):
    • The widening is one entry export of @objectstack/plugin-auth, ensureDefaultOrganizationExists (221 → 222 names), with a minor changeset. The runtime entry is byte-identical to main.
    • Its one caller is the ceremony. It needs the Default Organization's own producer (ADR-0131 D3: "exists before the first write that needs an owner"; D10 fate 3), and the boot that would create it is the one the guard now refuses before any plugin writes.
    • The alternative was a second producer of that row, which is the shape the one-definition item above removes. The docblocks state that no host composing a kernel calls it.
    • Accepted. Cross-lane: [PM seat] domain:services — 🟢 os-sales #6021 6249475347.
  5. Gates:
    • Locally: 80 derived commands, 0 NOT-MEASURED. The declared-wide families were run as well, including check:init-service-contract (red reproduced, then 0). eslint over the changed files exits 0.
    • CI on this head at this stamp: 13 success, 8 roster-shaped skips, 17 in_progress, 0 failure.
    • ⛔ Not ready until every check on the head is green.
  6. Deviations accepted:
    • files outside the listed surface, each named: the guard's producer in packages/runtime, plugin-auth (declared on [PM seat] domain:services — 🟢 os-sales #6021), the dogfood refusal-order test, and one docblock in dev-organization-ownership-upgrade.ts;
    • the folded storage-less fix;
    • the worktree recreated from the branch, with no history rewritten;
    • one main merge in round 0.
  7. Out of scope:
  8. Behaviour change a host sees: the refusal now arrives as the cause of the kernel's start-failure error. os serve and os dev walk the chain, and the changeset says so.
  9. Landing: ready plus auto-merge through the queue once every check on the current head is success or a roster skip. Upgrade 17 → 18: the organization-ownership ceremony leaves seed rows with NULL organization_id, the boot guard serves the unverified database, and an organization-scoped seed load inserts a twin of every such row #24282 closes on Fixes, and metadata-protocol(seed-loader): an organization-scoped load inserts a twin of every row whose natural key a NULL-organization row already holds, silently, instead of refusing it (leg B of #24282) #24301 (leg B) stays open as its own card.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24282-ceremony-null-org-guard branch October 18, 2026 14:21
This was referenced Oct 18, 2026
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