You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ADR-0131 D9 on a walled control plane: three framework writers land NULL organization_id rows on every sign-up (the self-registration grant, the verification mail, activity about org-less records) #24343
Filed by the repo:cloud#2 seat (seat post #24145, session session_01Cbdek8SKSHxddGqTPZXdqX) from the objectstack-ai/cloud#1979 measurement round (PR objectstack-ai/cloud#3021), out-of-scope finding 1: class b, Seam spec → runtime. objectstack-ai/cloud#1979's acceptance line 「zero NULL rows on the control plane」 cannot close while these writers run, and they are all framework code. ⛔ Not a claim.
D9: a write on a tenant-column object with no organization is derived under single (the Default Organization is the only one) and refused otherwise.
D7 / D3: an object whose writer attributes rows to an organization is tenant data and keeps the column, NOT NULL.
What was measured (framework pin be076dad28a0, a fresh control plane, isolated)
signup-org-flow's own boot and journey (17/17), then a census of the sqlite store. 98 tables, 50 with an organization column, and 46 of those hold no NULL. Cloud-written rows are all labelled. NULL organization_id:
table
NULL / total
writer
what the rows are
sys_user_permission_set
2 / 3
plugin-auth auth-manager.tssettleSelfRegistrationGrant ("falls back to an unscoped row only when no org is resolvable")
each self-registrant's cloud_control_plane_member grant
sys_email
3 / 6
plugin-auth's verification-mail persistence
the verification mails (related_objectsys_user); the 3 labelled rows are the invitations
sys_activity (shard sys_activity__r20261018)
27 / 40
plugin-audit's activity writer
activity about records with no organization: sys_user ×12, the NULL sys_email ×6, the 2 NULL grants, sys_organization, sys_platform_setting, sys_setting_audit, sys_jwks, sys_oauth_resource, sys_verification, sys_team_member
These are runtime writes on every sign-up (POST /api/v1/auth/sign-up/email and the verification send), not migration residue.
The ceremony does not catch them.packages/cli/src/utils/organization-ownership-apply.ts counts unattributable rows (fate 4) as advisory, "never held against the verdict". organization-ownership.ts:774 sets verified_at from complete && blocking === 0. So a control plane reads applied=t verified=t blocking=0 with these rows present. ADR-0131 C3's ruling reads the gate as satisfied only when the remaining NULL rows are the sharing-rule mirrors.
sys_audit_log.tenant_id also reads NULL on 35 / 48 rows, but that is D7's attribution field, not the anchor, so it is not counted here.
Wanted
For each of the three writers, the shape D9 and D7 already rule: the row gets an organization the writer can derive (the registrant's organization once it exists, the record's organization for activity), or the object leaves the tenant-column set (no column, D7). A writer with neither is refused, not let through as NULL. Which fate fits each table is the engine and services lanes' call.
Acceptance
The same census on a fresh control plane after a sign-up reads 0 NULL organization_id rows on these three tables, or the table carries no column.
The ceremony's advisory count for them reads 0, with a test.
Dedupe
500 objectstack issues (open and closed, by last update) scanned for settleSelfRegistrationGrant, NULL organization_id with a runtime writer, and resolveSystemInsertOrganization: none carries these writers. #24314 (injected columns on a better-auth table) is a different defect: nothing extends sys_organization on main, so it does not reach this line.
Patch round 2 · PR #24369 · new head 02778102c · session session_015nNHWXUVqXs5TqaLtf2aGt · answers contract review 6253189824
Remedy A: the narrowing is removed.buildSettingAuditWriter's optional logger is now { warn?: … } | { error: …; warn?: … }. That is main's accept set, plus a sink that carries error (declared required on that member). One report() writes to error when the sink has it, else to warn. The first lost history row still names the consequence and the fix.
Accept set measured. A temporary tsc probe in src exited 0 for six calls: no sink, {}, a typed { warn? }, a typed { error?; warn? }, { error } alone, and a non-function warn refused by @ts-expect-error. The probe was deleted (git status clean apart from the fix). New runtime case in settings-secret-organization.test.ts: no sink, {} and an { error }-only sink are all taken, and the error-only sink receives the report.
Gates, exit codes captured before any pipe.pnpm check:optional-error-sink exit 0. pnpm check:durability-log-level exit 0. @objectstack/service-settings (dependencies built first): typecheck exit 0, test 50 files / 876 passed.
Declaration.Clause-②: yes (widening) stays and is now true. The changeset clause is rewritten to say what the head does: the optional logger keeps every sink it accepted and also takes one carrying error. The PR body names the brief narrowing at bc7e2b791 and its removal (the body was edited once through the relay; the read-back is byte-identical).
Landed · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-18T18:54Z. PR #24369 (C8 stage 8b-0b, Part of #24343) has merged. Its contract review is record 6253507840 (TIER_DEFAULT, PASS on 02778102c).
merged: true at 2026-10-18T18:53:25Z; merge commit d65826fa7cef08145d6655c3fe500671aec678d2
origin/main
tip d65826fa, which is that merge commit; the queue ref gh-readonly-queue/main/pr-24369-… is gone
State change.pm:dispatched → pm:blocked, and the card is unassigned. It stays open, because the PR is Part of this card.
Delivered. Every framework producer that writes on a tenant-column object now names the organization it can derive:
plugin-auth: the self-registration grant and five auth mails;
plugin-audit: the activity fallback;
service-settings: secrets and history rows;
verify: persona grants.
Under single, that organization is the Default Organization.
Remainder. One part of the acceptance is still open: 0 NULL rows after a walled sign-up. Reaching it needs #24377 ruled first. #24377 decides the four walled fates: the self-registration grant, identity mail, activity about organization-less records, and deployment-level secrets with global-rung audit rows. Each ruling becomes a stage this seat dispatches before 8b.
Ruling-pointer (a premise correction) · from #24377's ruling at 6257510973 (batch #366 item 1) · director seat (summon #43 continuation 5, session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-19T01:11Z
PR #24369 (stage 8b-0b, this card) states 「No webhook producer writes sys_secret」. Read on origin/main44a5011fe623: the engine's encryptSecretFields (packages/objectql/src/engine.ts:10671, create :10733–:10746) writes sys_secret rows through the business write's driver options, and the driver stamps the organisation (sql-driver.ts:15653–:15665), so organisation-scoped secret rows exist today for sys_webhook secrets (sys-webhook.object.ts:220, :253), sys_flow_credential.value (:100) and app Field.secret fields — pinned at deployment-level-no-tenant-column.test.ts:80–:87. The stage's result is unaffected (those rows have an organisation); the sentence is wrong and the Q4 stage must keep that path working without the column. Recorded here so 8b's measurement does not inherit it.
Premise correction · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-19T01:17Z. ⛔ Not a claim, ⛔ no label change.
The director's delta 2 on #24377 (6257256417, Q4) falsifies one line of PR #24369's body: 「No webhook producer writes sys_secret」.
The engine's encryptSecretFields (packages/objectql/src/engine.ts about :10671, :10733–:10746) writes sys_secret through the business write's driver options.
So organization-scoped secret rows are written today for:
sys_webhook secrets (sys-webhook.object.ts about :220, :253);
sys_flow_credential.value (about :100);
app Field.secret fields (pin deployment-level-no-tenant-column.test.ts about :80–:87).
The datasource binder writes none.
Whatever stage carries #24377 Q4 builds on this corrected premise: a secret's ownership follows the row that references it. The merged code of #24369 is unaffected; only that sentence of its PR body is wrong.
Claim: PM loop round 5 · 2026-10-19T01:26Z
Session: session_01Y5583bs2RDd53MH6kM1ca3
Account: os-steve (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-24343-8b0c-activity-never-null (C8 stage 8b-0c; ruling Q3 A on #24377, 6257510973)
Worktree: the cloud session's own checkout (mode:cloud)
Domain: domain:services
Seat: epic:#15194 (the epic lane)
File surface, read on origin/main71fa166e; stop on breach and explain in the report:
packages/plugins/plugin-audit/src/audit-writers.ts (the activity mirror's organization derivation, about :1170–:1181 and :2230–:2231) and its tests;
the ownership ceremony's sys_activity fate: packages/cli/src/utils/organization-ownership-inventory.ts (about :518), with organization-ownership-plan.ts / organization-ownership-apply.ts only if the deletion of existing NULL rows needs them, and their tests.
Container & model: S, mode:cloud, model: TIER_DEFAULT (no path-derived mandate)
Clause-②: no
Responsibility: n/a — a ruled stage, not a defect fix
Thread-read: 6257602337
Serial constraints cleared:
Claim amendment · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-19T02:19Z. It amends claim 6257703026 (stage 8b-0c, still in flight) by adding a second, concurrent stage on this card. 8b-0c's branch and file surface are unchanged.
Worktree: the cloud session's own checkout (mode:cloud). Container & model:S, mode:cloud, model: TIER_DEFAULT.
File surface, read on origin/maine22defb13; stop on breach and explain in the report:
packages/plugins/plugin-auth/src/auth-manager.ts: settleSelfRegistrationGrant and its staging (about :4927–:4940 and :5610–:5715), the first-membership points (settleMembership about :4823–:4880, the organization hooks about :3578 and :3673), :8298–:8304, and its tests.
⛔ Not the ownership ceremony (packages/cli/src/utils/organization-ownership-*.ts), which 8b-0c holds. ⛔ Not audit-writers.ts. ⛔ Not plugin-email or service-settings.
Why it moves ahead of 8b-0d. Its precondition is met: cloud#3051 has reported (pointer 6258022252, report 6258001191) that cloud's control plane tolerates Q1 A as it is, and that creating the first organization needs no permission set. The plan put it after 8b-0d only because both edit auth-manager.ts. Measured on e22defb13, 8b-0d's mail sites (about :2006–:2041, :3862–:3882, :4113–:4123, :5649, :5827) and this stage's grant regions are far apart; only the helper pair at about :5627–:5652 is adjacent. With no 8b-0d in flight, the order between them is free. No open PR touches auth-manager.ts (scan of all 6 open PRs).
Closing lines. The PR says Part of #24343, not a closing keyword: Q2 (8b-0d) still lands on this card and closes it.
{
"issue": 24343,
"status": "done",
"branch": "claude/issue-24343-8b0c-activity-never-null",
"pr": "https://github.com/objectstack-ai/objectstack/pull/24429",
"session": "session_018oXkooW8d1rtxYGFYwnAiT (dispatched by the epic PM seat epic:#15194, session_01Y5583bs2RDd53MH6kM1ca3)",
"premise_still_valid": true,
"summary": "C8 stage 8b-0c, ruling 6257510973 item 3 (Q3 A). Identity verified: the newest Claim: on #24343 is 6257703026 and names this branch. (1) plugin-audit: the activity mirror keeps its derivation (the record's organization, then the session's, then the Default Organization under single). When none is derivable it writes no sys_activity row; 8b-0b's `?? null` arm is gone. The sys_audit_log row is unchanged. 19 plugin-audit suites booted the writer without AuthPlugin, so they modelled a wall; they now boot the single composition they test (src/__tests__/single-posture-tenancy.ts), with their assertions unchanged. (2) cli ceremony: sys_activity's inventory row (fate 3, subject anchor) gains a platform-only `underivableDeparture` (id underivable-activity). On any posture but single, the plan counts the NULL rows no anchor attributes as departing, not unattributable, and warns before the first write. The apply's mirror-deletion step deletes them after attribution, read from a fresh plan; it blocks with nothing deleted if that plan still derives an owner for a NULL row or reports one. A checkpointed re-run deletes any that appear later. The post-check reports the count as `deleted`. Under single the Default Organization takes them, and nothing is deleted. 471 insertions and 21 deletions in 28 files.",
"tests": "All runs on head 36af4c05; the tree is byte-identical to the WIP commits the gate sweep ran on, squashed with --force-with-lease before the PR. (1) plugin-audit `pnpm --filter @objectstack/plugin-audit test`: 56 files, 898 tests passed. Before the single fixture, the same run had 160 failures in 19 files, all missing-activity-row assertions. (2) typecheck: plugin-audit and cli exit 0. (3) cli `vitest run --project unit`: 309 files passed. 2 failed only on the precondition 'packages/cli is not built'; after `pnpm --filter @objectstack/cli build` they passed (2 files, 29 tests). (4) cli `--project integration` over the 15 organization-ownership*/apply-organization-ownership-gate suites in commands/migrate and utils: 13 files passed, 1 skipped (68 tests passed, 1 skipped). (5) new organization-ownership-apply.underivable-activity.test.ts (sqlite fixture). Walled isolated: the plan shows departure rows 2, unattributable 0, NOT NULL willReceive true, plus the warning. The apply attributes 1, deletes 2 (`DELETE ... WHERE organization_id IS NULL`, after the UPDATE) and leaves remaining 0; the re-run writes nothing. Single: departures [], attribution default-organization 2, apply attributed 3, deleted 0, warnings []. (6) dogfood identity-admin-fields-org-peer.dogfood.test.ts (showcase, single): 12/12 passed. (7) Ablations via scripts/ablation-replace.mjs, each restore proven (blob == HEAD, git diff HEAD empty). Mirror skip removed: 1 red. Plan underivable branch disabled: 3 walled reds. The single exclusion removed: the single case red. No dist rebuild needed: plugin-audit's vitest aliases to src, and the cli tests import ./*.js from src.",
"gates": "Derived with `dispatch-gates --commands --repo objectstack-ai/objectstack`: on the claimed paths (61), then with no paths after the changeset commit (72), plus the 53-family artifact-roster block on STDERR: 122 unique commands, each exit captured to a file before any pipe. `--ran` with ':: exit N' lines: 72 derived, 70 run, 2 NOT-MEASURED (DERIVED from exit 3). NOT MEASURED, exit 3 precondition: check:dual-build-cjs-loads and check:published-readme-exports (need a full `pnpm build`); check-plugin-teardown-shape --self-test (needs `git fetch --unshallow`). NOT MEASURED, no pull request context: check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths (exit 2, NOT WIRED without PR_NUMBER/PR_BODY); check-governed-queue-guard (exit 1, no GITHUB_EVENT_PATH). Every other command exited 0, including check:durability-log-level, check:tenant-audit-census, check:platform-object-tenancy-census, check:org-identifier, check:test-source-alias, check:cross-package-test-inputs, check:type-check-coverage, check:nul-bytes, the ADR-0087 / changeset-no-major / empty-changeset gates and check:i18n. CI on PR #24429 at the report: 23 check-runs, 21 in_progress, 1 success, 1 skipped. The seat reads its convergence.",
"mcp_calls": "0",
"api_writes": "2: (1) fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST /issues/24429/assignees os-steve), request fw-20261019T022917Z-0699e2; (2) fleet-write relay comment on #24343 (POST /repos/objectstack-ai/objectstack/issues/24343/comments), this report. Plus git pushes: the empty branch probe, 4 WIP pushes, 1 --force-with-lease squash.",
"open_questions": [
{
"question": "Existing walled NULL sys_activity rows whose record has an organization: delete them, or attribute them?",
"options": ["A: attribute them by the subject anchor (as before), and delete only what no anchor names (taken)", "B: delete every NULL row, the literal reading of 'existing null rows are deleted'"],
"recommendation": "A, which is what landed. Business need: activity about an organization's record is that organization's timeline, and the runtime would stamp it today, so deleting it loses real data. Long term: the ceremony applies the same rule the mirror now applies (derivable means kept with that organization, otherwise no row). AI error-proofing: one rule in two places, so there is no second dialect to learn. Startup focus: no new surface; the anchor already existed. Under single, B would also delete rows the Default Organization owns, contradicting 'under single nothing changes'."
},
{
"question": "Mechanism for the deletion: a declared mirror-deletion departure or a new platform-only inventory key?",
"options": ["A: platform-only InventoryEntry.underivableDeparture, classified after the anchors (taken)", "B: a spec-declared departure with a where predicate", "C: widen the spec supplement schema"],
"recommendation": "A. A declared departure is classified before the anchors, and its where runs over every NULL row, so B deletes attributable rows and every single-posture row. C changes a published schema (Clause-②) and lets a host declare a deletion, which nobody has asked for. A is the narrowest: one key, one table, platform-only, and the spec is unchanged."
},
{
"question": "A1 to A6 measurements against the PM assumptions",
"options": ["A1 holds: exactly one platform producer (audit-writers.ts persistAuditTrailRow, sys.object('sys_activity').create); installActivityStampHook stamps and creates none. App-authored direct inserts go through the engine, which is 8b's surface.", "A2 holds: the dogfood file is 12/12 green under single.", "A3 holds at unit level (audit-writers.test.ts): walled sign-up gives 0 sys_activity rows and 1 sys_audit_log row (tenant_id null); a record with an organization keeps its row with that organization. No isolated-stack boot test was added.", "A4 refined: the mirror-deletion departure cannot express 'after attribution', so a new key was used. The count is reported as TableOutcome.deleted and in the step summary. Walled: 2 deleted; single: 0.", "A5: Clause-② no. Layer 0 never served walled NULL rows, and 0 docs, skills or spec promises (grep).", "A6: 471 insertions and 21 deletions."],
"recommendation": "Read as evidence. Nothing here needs a decision."
}
],
"out_of_scope_findings": [
"carrier: none · noted in the PR's Acceptance notes, not filed: derivableActivityOrganization() swallows a throwing tenancy.defaultOrgId() as 'none'. Under single such a throw now drops the activity row, where it used to write a NULL row; the ledger row still lands. The throw is not measured to occur. Dedupe words: defaultOrgId throw, activity row dropped, tenancy service error.",
"carrier: none · noted, not filed: the 19 plugin-audit suites that boot the audit writer without a tenancy service now share src/__tests__/single-posture-tenancy.ts. Any new suite that boots AuditPlugin without it models a wall and writes no activity, so it must opt in."
]
}
Landed · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-19T03:27Z. PR #24429 (C8 stage 8b-0c, Part of #24343, Part of #15212) has merged.
Review. The seat reviewed it in-seat at TIER_DEFAULT. Clause-②: no, and no governed path is touched: check-governed-merges --pr 24429 reads "NOT governed", 492 changed lines. CI was green on 36af4c053, with 34 success and 6 skipped.
merged: true at 2026-10-19T03:19:33Z; merge commit 538f923cb
origin/main
tip 538f923cb, which is that merge commit
State change: none. The card stays pm:dispatched. Stage 8b-0f is in flight on it (claim amendment 6258269794), and 8b-0d, as part of 8b-0de, still lands here and closes it.
Delivered (ruling 6257510973 item 3, Q3 A).
The activity mirror writes a sys_activity row only when an organization is derivable: the record's, then the session's, then the Default Organization under single. It never writes a NULL row.
The sys_audit_log row is unchanged.
The ownership ceremony attributes a walled NULL activity row by its subject anchor where it can. It deletes only the rows that no anchor names, under the platform-only underivableDeparture (underivable-activity). The post-check counts them as deleted.
Under single, the Default Organization takes those rows and nothing is deleted.
The seat accepts the dev's choices (os-dev-report 6258392729):
Open question 1, A. Attribute what an anchor names, and delete only the rest. This is the same rule the mirror now applies, and it keeps "under single nothing changes".
Open question 2, A. A platform-only inventory key, with no spec change.
Carried on, not filed (the two out-of-scope findings):
A throwing defaultOrgId() now drops the activity row instead of writing a NULL one. The throw has not been measured to occur.
A new plugin-audit suite must opt in to src/__tests__/single-posture-tenancy.ts.
{
"issue": 24343,
"stage": "C8 8b-0f (ruling Q1 A on #24377, record 6257510973 item 1; claim amendment 6258269794, verified: it names this branch)",
"status": "done",
"branch": "claude/issue-24343-8b0f-grant-on-first-membership",
"pr": "https://github.com/objectstack-ai/objectstack/pull/24453",
"session": "session_01KYBqeqPDxig2tPYhHQxYNX",
"premise_still_valid": true,
"summary": "The self-registration grant now settles on the registrant's first membership, under every posture, and is never written without an organization. In plugin-auth auth-manager.ts, settleSelfRegistrationGrant (user.create.after) grants at once when the registrant already holds a membership. Under single that is the Default Organization, which the reconciler bound a moment earlier in the same hook, so single is unchanged. When the registrant holds none (a wall), it persists a pending claim in sys_verification instead: identifier 'self-registration-grant:' plus the user id, value = the set name, a 365-day expiry. sys_verification already exists, is deployment-level (no organization column, inventory noColumn) and survives a restart. A sys_member insert middleware, registered once per engine from the constructor, catches every membership writer, consumes the claim and writes exactly one grant owned by that membership's organization. A later membership writes none. No new object, column, export or schema; Clause-②: no. Pins: 9 unit cases plus a real-stack walled dogfood pin (sign-up writes 0 grants; POST /auth/organization/create writes 1, owned by the new organization; a second organization writes none). The ablation turns both red.",
"tests": "At HEAD 29b223ed3. @objectstack/plugin-auth test: 165 files, 3165 passed, 10 skipped (TEST_EXIT=0). typecheck exit 0, check:test-typecheck OK. Dogfood after a plugin-auth rebuild, 3 files / 7 passed: self-registration-grant-first-membership-walled (new, isolated via posture-only), signup-organization-census (single: one grant owned by the Default Organization), org-create-default-team (walled). New unit file self-registration-grant-first-membership.test.ts, 9/9. Ablation through scripts/ablation-replace.mjs --hold, under an EXIT trap: the claim write was replaced by a sign-up grant with no organization, marker ups_ABLATED_, anchor 1->0, blob debc7651->624afb11. plugin-auth rebuild: the DTS step failed on TS6133 (the claim method was now unused), but the JS was emitted, and ablation-dist-preflight found the marker in dist/index.js and dist/index.mjs (exit 0). The walled dogfood pin went red, 2 failed / 1 passed: 'expected [ {..} ] to deeply equal []' (the sign-up wrote a grant) and 'expected null to be ORG_ID'. The unit file went red, 5 failed / 4 passed. Restore leg: blob == HEAD debc7651, git diff HEAD empty, rebuild exit 0, preflight --absent exit 0 with the tree clean, dogfood 3/3 green.",
"gates": "Derived at 29b223ed3 with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack and no paths (a first derivation over the claimed paths gave 58; the no-path set at the final head is 108 families), plus the 51-row artifact roster from STDERR. Every command ran with its exit code captured before any pipe. --ran reconciliation: '108 derived famil(ies) accounted for, 108 run, 0 NOT-MEASURED' (exit 0). Red, then fixed on the way: (1) check-tenant-audit-census exit 1, write call sites 229->231; fixed by regenerating its two artefacts (node scripts/tenant-audit-census.mjs --write), updating the page's prose figures, and making the three new writes state { context: { isSystem: true } }, so the provable-yield figure stays 2; then gate exit 0 and --self-test exit 0. (2) check:engine-double-contract exit 1 (new delete double not in the ledger); --write added one row, then exit 0. (3) check:where-matcher exit 1 (the new double read combinators as field names); the double now refuses them, then exit 0. Prerequisite, not findings: check:skill-examples, check:published-readme-exports and check:dual-build-cjs-loads exited 3 (unbuilt packages) and each exited 0 after building those packages; check-plugin-teardown-shape --self-test exited 3 on the shallow clone and exited 0 after fetching its pinned commit 621a4876. Roster rows that need a PR context, NOT WIRED locally: check-closing-target-claim exit 2, check-single-claim-paths exit 2, check-governed-queue-guard exit 1 (no GITHUB_EVENT_PATH); CI's reading counts. check-partof-closing-keyword run with the PR body as PR_BODY: exit 0. After merging origin/main (db1f1fd2, spec only): spec build and check:generated exit 0. Re-derived after the last commit: identical family list and roster. Not run locally: CI-owned families (pnpm lint, test:repo). PR check-runs not awaited (in_progress at report time).",
"line_budget": "10 files, +632 / -131 = 763 changed lines against the merge-base (estimate 0.3-0.6k; cap 3,000). auth-manager.ts 255; tests 413 (new unit 224, new dogfood 102, three edited 87); tenant-audit census artefacts 76; changeset 14; engine-double ledger 5.",
"files_changed": [
".changeset/24343-8b0f-grant-on-first-membership.md",
"packages/plugins/plugin-auth/src/auth-manager.ts",
"packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts",
"packages/plugins/plugin-auth/src/audience-posture.test.ts",
"packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts",
"packages/plugins/plugin-auth/src/producers-name-organization.test.ts",
"packages/qa/dogfood/test/self-registration-grant-first-membership-walled.dogfood.test.ts",
"content/docs/permissions/tenant-audit-census.mdx",
"docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
"scripts/engine-double-contract.pinned.json"
],
"measurements": {
"A1_paths": "Writers of a user's sys_member row on this tree: better-auth organization/create (creator as owner) and accept-invitation, better-auth server-side add-member, reconcileMembership (user.create.after, session.create.before, the one-time backfill), ensureDefaultOrganization's owner bind (ensure-default-organization.ts :608), and host user.create.after hooks. No SCIM or import path writes sys_member directly (they create users; membership then comes from the reconciler). All of them write through the data engine, so ql.registerMiddleware sees them; the comment at auth-manager.ts :852-:877 refutes the old adapter-bypass claim, and plugin-security's organization_admin auto-grant uses the same seam (security-plugin.ts :4617). The walled dogfood pin drives better-auth's real organization create through it: 1 grant, owned by the new organization.",
"A1_single": "Sign-up under single ends with exactly the grant it had: one row naming member_default, owned by the Default Organization (signup-organization-census green at the final head; unit case 'writes exactly the grant it always had').",
"A2_persistence": "The in-memory stage (10-minute TTL, keyed by email) cannot cross a restart. Nothing persisted marks a self-registrant: admission decides from the request (creation class, bootstrap probe, pending-invitation lookup; audience-posture.ts :441-:500), sys_user.source distinguishes only idp_provisioned / env_native, and sys_account.provider_id is 'credential' for admin-created users too. The narrowest existing persisted store is sys_verification: better-auth's one-shot claim table keyed by identifier with an expiry, deployment-level (platform census reach 'out', tenantField null; ownership inventory noColumn :272-:279), and swept on expiry by better-auth (internal-adapter findVerificationValue deletes expired rows). Its value column is internal: true, so the claim is read back through recoverInternalFieldsForSystemRead. No new sys_* object and no inventory row.",
"A3_existing_null_grants": "Not touched (ceremony files are 8b-0c's). See out_of_scope_findings[0].",
"A4_pins": "Walled: sign-up 0 sys_user_permission_set rows, the first organization 1 row with that organization, a second organization 0 more (dogfood plus unit). Single: unchanged. Ablation red, as cited in tests. The existing plugin-auth suites are green; three existing cases were updated (named in the PR body).",
"A5_clause2": "no. No packages/spec byte, export, route or object definition changed. AudienceConfigSchema.selfRegistrationPermissionSet (auth-config.zod.ts :437-:452), the generated reference page content/docs/references/system/auth-config.mdx :57/:174, the settings manifest (auth.manifest.ts :118-:124) and its translations all say 'the permission set granted to each self-registrant'. None promises the grant at sign-up time. Releases v17 17-3 describes the declaration only."
},
"deviations": [
"Worktree: the primary checkout held this branch, so it was detached (git checkout --detach) and the branch checked out in ../objectstack-issue-24343-8b0f. The empty branch was pushed before any edit.",
"File surface: beyond auth-manager.ts and its tests, the diff carries three gate-forced riders. (a) content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md, regenerated plus prose figures, because check-tenant-audit-census holds them to the write-call-site population this stage moves. (b) scripts/engine-double-contract.pinned.json, one row. (c) A new dogfood test file (the walled real-stack pin). All are named in the PR body. No ceremony, plugin-audit, plugin-sharing, ADR-0131, plugin-email or service-settings file was touched, and no identity-mail send site was touched.",
"Helper pair at about :5627-:5652: the doc comment of derivableWriteOrganizationId only (the grant is no longer one of its readers). No code change there.",
"producers-name-organization.test.ts lost its two grant cases. The single one is re-pinned in the new file. The walled one asserted the retired behaviour (a grant with no organization), and the new walled pins assert the opposite. Named in the PR body.",
"Size 763 changed lines, above the 0.3-0.6k estimate: 413 are tests and 76 are the regenerated census artefacts.",
"origin/main (db1f1fd2, one spec-only commit) was merged once before the PR opened, per AGENTS.md section 10. The spec was rebuilt and check:generated is green."
],
"mcp_calls": "0 — no MCP GitHub tool called (reads were unauthenticated curl to api.github.com).",
"api_writes": "2 relay strokes as objectstack-fleet[bot] through scripts/pm/fleet-write/dispatch.mjs. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24453/assignees [os-steve]; the body read back byte-identical (9333 bytes) and the assignee is os-steve. (2) This report: POST /repos/objectstack-ai/objectstack/issues/24343/comments. git push: 9 pushes to the branch (not REST). No labels written: none named, and the PR carries a changeset.",
"open_questions": [
{
"question": "Where the pending grant lives between sign-up and the first membership, and for how long. Built: a sys_verification claim with a 365-day expiry.",
"options": [
"A. sys_verification claim, 365 days (built). Existing deployment-level one-shot store; better-auth sweeps it on expiry; no new object; a registrant who joins after a year holds only the baseline.",
"B. The same claim with no practical expiry (a far-future expires_at). No lost grant, but an orphan row per never-joining registrant, kept forever.",
"C. A dedicated pending-grant object. Most explicit, but it is a new sys_* object, needs an ownership-inventory row in 8b-0c's file, and is a new capability surface."
],
"recommendation": "A. Real demand: the one walled deployment measured (cloud#3051) holds the same set as its isDefault baseline, so a lapsed claim costs nothing there, and nothing else in the tree reads the grant in that window. Long term: the grant stays one organization row written with a membership, and the claim is short-lived plumbing in an existing store. Hard for AI to get wrong: one writer and one seam, with a loud error when no seam exists. Startup scope: no new object and no new gate. The 365 is a judgement call; the maintainer may prefer a different horizon."
},
{
"question": "single with membershipPolicy 'invite-only': a self-registrant gets no membership at sign-up, so the grant now also waits for the first membership there. Before, it was written at once, owned by the Default Organization.",
"options": [
"A. Keep (built): the ruling says 'under every posture', and a grant with no membership is exactly what D3 retires.",
"B. Special-case single to grant the Default Organization at sign-up even without a membership."
],
"recommendation": "A. It follows the ruling literally and keeps one rule. B reintroduces a grant with no membership behind it. Under the default 'auto' policy, single is unchanged."
}
],
"out_of_scope_findings": [
"carrier: the epic seat's next ceremony stage (8b-0c holds the ceremony) · A3, the fate of existing walled NULL-organization sys_user_permission_set rows from earlier self-registrations. Evidence: inventory organization-ownership-inventory.ts :572 gives sys_user_permission_set fate 'attribution' with anchors [], so under a wall these rows fall to plan.unattributable (organization-ownership-plan.ts :1215-:1225), and since 8c-0 they block certification. The runtime rule this stage lands: the grant belongs to the user's first membership. Recommendation: (1) a NULL row whose user holds one or more memberships is attributed to the user's earliest sys_member row (created_at ascending), the same 'first membership' the runtime uses; (2) a NULL row whose user holds none is deleted, not re-parked. The cloud#3051 report (6258022252) shows the set duplicates the isDefault baseline on the one walled deployment, and re-parking would couple the ceremony to plugin-auth's private claim shape (identifier prefix 'self-registration-grant:'). Alternative for (2): convert the row to that claim, so the runtime grants it on a later first membership. Under a wall the card's census names the self-registration grant as the only NULL writer on this table (2/3). The single-posture admin promotion grant is #11979's.",
"class: none (observation, note only) · reach: n/a · scripts/tenant-audit-census.mjs classifies sys_verification as 'enabled' (tenancy-enabled), while scripts/platform-object-tenancy-census.json :690-:696 has it reach 'out', tenantField null, and the ownership inventory lists it noColumn. The two instruments disagree on one object. carrier: none · noted in the PR's riders · dedupe words: tenant-audit census sys_verification enabled classification managedBy better-auth",
"class: none (doc polish, note only) · reach: n/a · AudienceConfigSchema.selfRegistrationPermissionSet's describe ('granted to each self-registrant') could say 'with the self-registrant's first membership'. Not changed here, because no published schema byte should move. carrier: none"
]
}
Landed · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-19T05:24Z. PR #24453 (C8 stage 8b-0f, Part of #24343, Part of #15212) has merged.
Review. The seat reviewed it in-seat at TIER_DEFAULT. It declares Clause-②: no, and no governed path is touched: check-governed-merges --pr 24453 reports "NOT governed" over 763 changed lines.
merged: true at 2026-10-19T05:22:56Z; merge commit 95246668d
State change: none. The card stays pm:dispatched. Its remaining stage, Q2 (8b-0d), moved to 18.x with the 18.0 scope (ruling records 6259608309 and 6259698357 on #15194), so the card stays open until then.
Delivered (ruling 6257510973 item 1, Q1 A).
The self-registration grant settles on the registrant's first membership, under every posture.
Under single it is granted at sign-up, owned by the Default Organization, as before.
Under a wall, sign-up writes a pending claim in sys_verification. That object is deployment-level and survives a restart; the claim expires after 365 days. A sys_member insert middleware turns the claim into exactly one grant, owned by the first membership's organization.
The grant is never written without an organization.
The seat accepts the dev's choices (os-dev-report 6259665833):
Open question 1, A: the sys_verification claim, 365 days, with no new object.
Open question 2, A: under single with invite-only, the grant also waits for the first membership. The ruling says 「under every posture」.
Carried to 18.x: the out-of-scope A3 finding, the fate of existing walled NULL self-registration grants, goes to the ceremony. The dev recommends attributing such a grant to the user's earliest membership, or deleting it when the user has none. It rides with the 18.x upgrade path.
Maintainer decision: off the 18.0 label, to 18.x · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-19T07:57Z
Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
Provenance. The seat put this to the maintainer in chat, at the end of its p1 sweep: 「三张只跟多组织隔离部署(walled 模式)有关的卡挂着 v18 标签……我倾向把这三张的 v18 标签去掉,改排 18.x」. The maintainer answered 「两件事 同意」.
What changes:target:v18 comes off. The stale finding label comes off at the same touch. Nothing else moves: p1, pm:dispatched, the epic seat's claim and its stages stay as they are.
Dependents: objectstack-ai/cloud#1979 moves with it.
Epic seat (epic:#15194, session_01Y5583bs2RDd53MH6kM1ca3) · 2026-10-19T12:06Z. State change: pm:dispatched → pm:blocked. Nothing is in flight on this card. Its 18.0 part has landed, and its remaining stages are on the 18.x line.
Under ruling record 6266750393 on #15194, the 18.x line is serial and keeps main releasable after every merge. Step 0 (#24522) measures and builds the 18.0→HEAD upgrade harness. Each remaining stage of this card then gets its own child card, filed from that report and carrying R1–R4. This card stays open as their parent, with its history kept, and closes when they close.
Activity
objectstack-fleet commentedon Oct 18, 2026
os-dev-report addendum
Patch round 2 · PR #24369 · new head
02778102c· sessionsession_015nNHWXUVqXs5TqaLtf2aGt· answers contract review 6253189824buildSettingAuditWriter's optionalloggeris now{ warn?: … } | { error: …; warn?: … }. That ismain's accept set, plus a sink that carrieserror(declared required on that member). Onereport()writes toerrorwhen the sink has it, else towarn. The first lost history row still names the consequence and the fix.srcexited 0 for six calls: no sink,{}, a typed{ warn? }, a typed{ error?; warn? },{ error }alone, and a non-functionwarnrefused by@ts-expect-error. The probe was deleted (git statusclean apart from the fix). New runtime case insettings-secret-organization.test.ts: no sink,{}and an{ error }-only sink are all taken, and the error-only sink receives the report.pnpm check:optional-error-sinkexit 0.pnpm check:durability-log-levelexit 0.@objectstack/service-settings(dependencies built first): typecheck exit 0, test 50 files / 876 passed.Clause-②: yes (widening)stays and is now true. The changeset clause is rewritten to say what the head does: the optional logger keeps every sink it accepted and also takes one carryingerror. The PR body names the brief narrowing atbc7e2b791and its removal (the body was edited once through the relay; the read-back is byte-identical).Part of #24343; no walled fate built ([Decision] C8 under a wall: four framework writes that have no organization to name — self-registration grant, identity mail, activity, deployment-level secrets #24377); PR left draft; no merge oforigin/main.git push, 1 PR bodyPATCH, and this comment. MCP calls: 0.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
Landed · epic PM
session_01Y5583bs2RDd53MH6kM1ca3(os-steve) · 2026-10-18T18:54Z. PR #24369 (C8 stage 8b-0b,Part of #24343) has merged. Its contract review is record 6253507840 (TIER_DEFAULT, PASS on02778102c).merged: trueat 2026-10-18T18:53:25Z; merge commitd65826fa7cef08145d6655c3fe500671aec678d2origin/maind65826fa, which is that merge commit; the queue refgh-readonly-queue/main/pr-24369-…is goneState change.
pm:dispatched→pm:blocked, and the card is unassigned. It stays open, because the PR isPart ofthis card.Delivered. Every framework producer that writes on a tenant-column object now names the organization it can derive:
plugin-auth: the self-registration grant and five auth mails;plugin-audit: the activity fallback;service-settings: secrets and history rows;verify: persona grants.Under
single, that organization is the Default Organization.Remainder. One part of the acceptance is still open: 0 NULL rows after a walled sign-up. Reaching it needs #24377 ruled first. #24377 decides the four walled fates: the self-registration grant, identity mail, activity about organization-less records, and deployment-level secrets with global-rung audit rows. Each ruling becomes a stage this seat dispatches before 8b.
Blocked-by: #24377
objectstack-fleet commentedon Oct 19, 2026
Ruling-pointer (a premise correction) · from #24377's ruling at 6257510973 (batch #366 item 1) · director seat (summon #43 continuation 5,
session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-19T01:11ZPR #24369 (stage 8b-0b, this card) states 「No webhook producer writes sys_secret」. Read on
origin/main44a5011fe623: the engine'sencryptSecretFields(packages/objectql/src/engine.ts:10671, create:10733–:10746) writessys_secretrows through the business write's driver options, and the driver stamps the organisation (sql-driver.ts:15653–:15665), so organisation-scoped secret rows exist today forsys_webhooksecrets (sys-webhook.object.ts:220,:253),sys_flow_credential.value(:100) and appField.secretfields — pinned atdeployment-level-no-tenant-column.test.ts:80–:87. The stage's result is unaffected (those rows have an organisation); the sentence is wrong and the Q4 stage must keep that path working without the column. Recorded here so 8b's measurement does not inherit it.objectstack-fleet commentedon Oct 19, 2026
Premise correction · epic PM
session_01Y5583bs2RDd53MH6kM1ca3(os-steve) · 2026-10-19T01:17Z. ⛔ Not a claim, ⛔ no label change.The director's delta 2 on #24377 (6257256417, Q4) falsifies one line of PR #24369's body: 「No webhook producer writes sys_secret」.
encryptSecretFields(packages/objectql/src/engine.tsabout:10671,:10733–:10746) writessys_secretthrough the business write's driver options.sys_webhooksecrets (sys-webhook.object.tsabout:220,:253);sys_flow_credential.value(about:100);Field.secretfields (pindeployment-level-no-tenant-column.test.tsabout:80–:87).Whatever stage carries #24377 Q4 builds on this corrected premise: a secret's ownership follows the row that references it. The merged code of #24369 is unaffected; only that sentence of its PR body is wrong.
objectstack-fleet commentedon Oct 19, 2026
Claim: PM loop round 5 · 2026-10-19T01:26Z
Session:
session_01Y5583bs2RDd53MH6kM1ca3Account:
os-steve(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-24343-8b0c-activity-never-null(C8 stage 8b-0c; ruling Q3 A on #24377, 6257510973)Worktree: the cloud session's own checkout (
mode:cloud)Domain:
domain:servicesSeat:
epic:#15194(the epic lane)File surface, read on
origin/main71fa166e; stop on breach and explain in the report:packages/plugins/plugin-audit/src/audit-writers.ts(the activity mirror's organization derivation, about:1170–:1181and:2230–:2231) and its tests;sys_activityfate:packages/cli/src/utils/organization-ownership-inventory.ts(about:518), withorganization-ownership-plan.ts/organization-ownership-apply.tsonly if the deletion of existing NULL rows needs them, and their tests.Container & model:
S,mode:cloud,model: TIER_DEFAULT(no path-derived mandate)Clause-②: no
Responsibility: n/a — a ruled stage, not a defect fix
Thread-read: 6257602337
Serial constraints cleared:
objectstack-fleet commentedon Oct 19, 2026
Claim amendment · epic PM
session_01Y5583bs2RDd53MH6kM1ca3(os-steve) · 2026-10-19T02:19Z. It amends claim 6257703026 (stage 8b-0c, still in flight) by adding a second, concurrent stage on this card. 8b-0c's branch and file surface are unchanged.claude/issue-24343-8b0f-grant-on-first-membershipmode:cloud). Container & model:S,mode:cloud,model: TIER_DEFAULT.origin/maine22defb13; stop on breach and explain in the report:packages/plugins/plugin-auth/src/auth-manager.ts:settleSelfRegistrationGrantand its staging (about:4927–:4940and:5610–:5715), the first-membership points (settleMembershipabout:4823–:4880, the organization hooks about:3578and:3673),:8298–:8304, and its tests.packages/cli/src/utils/organization-ownership-*.ts), which 8b-0c holds. ⛔ Notaudit-writers.ts. ⛔ Not plugin-email or service-settings.6258001191) that cloud's control plane tolerates Q1 A as it is, and that creating the first organization needs no permission set. The plan put it after 8b-0d only because both editauth-manager.ts. Measured one22defb13, 8b-0d's mail sites (about:2006–:2041,:3862–:3882,:4113–:4123,:5649,:5827) and this stage's grant regions are far apart; only the helper pair at about:5627–:5652is adjacent. With no 8b-0d in flight, the order between them is free. No open PR touchesauth-manager.ts(scan of all 6 open PRs).Part of #24343, not a closing keyword: Q2 (8b-0d) still lands on this card and closes it.objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{ "issue": 24343, "status": "done", "branch": "claude/issue-24343-8b0c-activity-never-null", "pr": "https://github.com/objectstack-ai/objectstack/pull/24429", "session": "session_018oXkooW8d1rtxYGFYwnAiT (dispatched by the epic PM seat epic:#15194, session_01Y5583bs2RDd53MH6kM1ca3)", "premise_still_valid": true, "summary": "C8 stage 8b-0c, ruling 6257510973 item 3 (Q3 A). Identity verified: the newest Claim: on #24343 is 6257703026 and names this branch. (1) plugin-audit: the activity mirror keeps its derivation (the record's organization, then the session's, then the Default Organization under single). When none is derivable it writes no sys_activity row; 8b-0b's `?? null` arm is gone. The sys_audit_log row is unchanged. 19 plugin-audit suites booted the writer without AuthPlugin, so they modelled a wall; they now boot the single composition they test (src/__tests__/single-posture-tenancy.ts), with their assertions unchanged. (2) cli ceremony: sys_activity's inventory row (fate 3, subject anchor) gains a platform-only `underivableDeparture` (id underivable-activity). On any posture but single, the plan counts the NULL rows no anchor attributes as departing, not unattributable, and warns before the first write. The apply's mirror-deletion step deletes them after attribution, read from a fresh plan; it blocks with nothing deleted if that plan still derives an owner for a NULL row or reports one. A checkpointed re-run deletes any that appear later. The post-check reports the count as `deleted`. Under single the Default Organization takes them, and nothing is deleted. 471 insertions and 21 deletions in 28 files.", "tests": "All runs on head 36af4c05; the tree is byte-identical to the WIP commits the gate sweep ran on, squashed with --force-with-lease before the PR. (1) plugin-audit `pnpm --filter @objectstack/plugin-audit test`: 56 files, 898 tests passed. Before the single fixture, the same run had 160 failures in 19 files, all missing-activity-row assertions. (2) typecheck: plugin-audit and cli exit 0. (3) cli `vitest run --project unit`: 309 files passed. 2 failed only on the precondition 'packages/cli is not built'; after `pnpm --filter @objectstack/cli build` they passed (2 files, 29 tests). (4) cli `--project integration` over the 15 organization-ownership*/apply-organization-ownership-gate suites in commands/migrate and utils: 13 files passed, 1 skipped (68 tests passed, 1 skipped). (5) new organization-ownership-apply.underivable-activity.test.ts (sqlite fixture). Walled isolated: the plan shows departure rows 2, unattributable 0, NOT NULL willReceive true, plus the warning. The apply attributes 1, deletes 2 (`DELETE ... WHERE organization_id IS NULL`, after the UPDATE) and leaves remaining 0; the re-run writes nothing. Single: departures [], attribution default-organization 2, apply attributed 3, deleted 0, warnings []. (6) dogfood identity-admin-fields-org-peer.dogfood.test.ts (showcase, single): 12/12 passed. (7) Ablations via scripts/ablation-replace.mjs, each restore proven (blob == HEAD, git diff HEAD empty). Mirror skip removed: 1 red. Plan underivable branch disabled: 3 walled reds. The single exclusion removed: the single case red. No dist rebuild needed: plugin-audit's vitest aliases to src, and the cli tests import ./*.js from src.", "gates": "Derived with `dispatch-gates --commands --repo objectstack-ai/objectstack`: on the claimed paths (61), then with no paths after the changeset commit (72), plus the 53-family artifact-roster block on STDERR: 122 unique commands, each exit captured to a file before any pipe. `--ran` with ':: exit N' lines: 72 derived, 70 run, 2 NOT-MEASURED (DERIVED from exit 3). NOT MEASURED, exit 3 precondition: check:dual-build-cjs-loads and check:published-readme-exports (need a full `pnpm build`); check-plugin-teardown-shape --self-test (needs `git fetch --unshallow`). NOT MEASURED, no pull request context: check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths (exit 2, NOT WIRED without PR_NUMBER/PR_BODY); check-governed-queue-guard (exit 1, no GITHUB_EVENT_PATH). Every other command exited 0, including check:durability-log-level, check:tenant-audit-census, check:platform-object-tenancy-census, check:org-identifier, check:test-source-alias, check:cross-package-test-inputs, check:type-check-coverage, check:nul-bytes, the ADR-0087 / changeset-no-major / empty-changeset gates and check:i18n. CI on PR #24429 at the report: 23 check-runs, 21 in_progress, 1 success, 1 skipped. The seat reads its convergence.", "mcp_calls": "0", "api_writes": "2: (1) fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST /issues/24429/assignees os-steve), request fw-20261019T022917Z-0699e2; (2) fleet-write relay comment on #24343 (POST /repos/objectstack-ai/objectstack/issues/24343/comments), this report. Plus git pushes: the empty branch probe, 4 WIP pushes, 1 --force-with-lease squash.", "open_questions": [ { "question": "Existing walled NULL sys_activity rows whose record has an organization: delete them, or attribute them?", "options": ["A: attribute them by the subject anchor (as before), and delete only what no anchor names (taken)", "B: delete every NULL row, the literal reading of 'existing null rows are deleted'"], "recommendation": "A, which is what landed. Business need: activity about an organization's record is that organization's timeline, and the runtime would stamp it today, so deleting it loses real data. Long term: the ceremony applies the same rule the mirror now applies (derivable means kept with that organization, otherwise no row). AI error-proofing: one rule in two places, so there is no second dialect to learn. Startup focus: no new surface; the anchor already existed. Under single, B would also delete rows the Default Organization owns, contradicting 'under single nothing changes'." }, { "question": "Mechanism for the deletion: a declared mirror-deletion departure or a new platform-only inventory key?", "options": ["A: platform-only InventoryEntry.underivableDeparture, classified after the anchors (taken)", "B: a spec-declared departure with a where predicate", "C: widen the spec supplement schema"], "recommendation": "A. A declared departure is classified before the anchors, and its where runs over every NULL row, so B deletes attributable rows and every single-posture row. C changes a published schema (Clause-②) and lets a host declare a deletion, which nobody has asked for. A is the narrowest: one key, one table, platform-only, and the spec is unchanged." }, { "question": "A1 to A6 measurements against the PM assumptions", "options": ["A1 holds: exactly one platform producer (audit-writers.ts persistAuditTrailRow, sys.object('sys_activity').create); installActivityStampHook stamps and creates none. App-authored direct inserts go through the engine, which is 8b's surface.", "A2 holds: the dogfood file is 12/12 green under single.", "A3 holds at unit level (audit-writers.test.ts): walled sign-up gives 0 sys_activity rows and 1 sys_audit_log row (tenant_id null); a record with an organization keeps its row with that organization. No isolated-stack boot test was added.", "A4 refined: the mirror-deletion departure cannot express 'after attribution', so a new key was used. The count is reported as TableOutcome.deleted and in the step summary. Walled: 2 deleted; single: 0.", "A5: Clause-② no. Layer 0 never served walled NULL rows, and 0 docs, skills or spec promises (grep).", "A6: 471 insertions and 21 deletions."], "recommendation": "Read as evidence. Nothing here needs a decision." } ], "out_of_scope_findings": [ "carrier: none · noted in the PR's Acceptance notes, not filed: derivableActivityOrganization() swallows a throwing tenancy.defaultOrgId() as 'none'. Under single such a throw now drops the activity row, where it used to write a NULL row; the ledger row still lands. The throw is not measured to occur. Dedupe words: defaultOrgId throw, activity row dropped, tenancy service error.", "carrier: none · noted, not filed: the 19 plugin-audit suites that boot the audit writer without a tenancy service now share src/__tests__/single-posture-tenancy.ts. Any new suite that boots AuditPlugin without it models a wall and writes no activity, so it must opt in." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Landed · epic PM
session_01Y5583bs2RDd53MH6kM1ca3(os-steve) · 2026-10-19T03:27Z. PR #24429 (C8 stage 8b-0c,Part of #24343,Part of #15212) has merged.Review. The seat reviewed it in-seat at
TIER_DEFAULT.Clause-②: no, and no governed path is touched:check-governed-merges --pr 24429reads "NOT governed", 492 changed lines. CI was green on36af4c053, with 34 success and 6 skipped.merged: trueat 2026-10-19T03:19:33Z; merge commit538f923cborigin/main538f923cb, which is that merge commitState change: none. The card stays
pm:dispatched. Stage 8b-0f is in flight on it (claim amendment 6258269794), and 8b-0d, as part of 8b-0de, still lands here and closes it.Delivered (ruling 6257510973 item 3, Q3 A).
sys_activityrow only when an organization is derivable: the record's, then the session's, then the Default Organization undersingle. It never writes a NULL row.sys_audit_logrow is unchanged.underivableDeparture(underivable-activity). The post-check counts them asdeleted.single, the Default Organization takes those rows and nothing is deleted.The seat accepts the dev's choices (os-dev-report 6258392729):
singlenothing changes".Carried on, not filed (the two out-of-scope findings):
defaultOrgId()now drops the activity row instead of writing a NULL one. The throw has not been measured to occur.src/__tests__/single-posture-tenancy.ts.Both are noted for the 8c-T pins.
objectstack-fleet commentedon Oct 19, 2026
os-dev-report
{ "issue": 24343, "stage": "C8 8b-0f (ruling Q1 A on #24377, record 6257510973 item 1; claim amendment 6258269794, verified: it names this branch)", "status": "done", "branch": "claude/issue-24343-8b0f-grant-on-first-membership", "pr": "https://github.com/objectstack-ai/objectstack/pull/24453", "session": "session_01KYBqeqPDxig2tPYhHQxYNX", "premise_still_valid": true, "summary": "The self-registration grant now settles on the registrant's first membership, under every posture, and is never written without an organization. In plugin-auth auth-manager.ts, settleSelfRegistrationGrant (user.create.after) grants at once when the registrant already holds a membership. Under single that is the Default Organization, which the reconciler bound a moment earlier in the same hook, so single is unchanged. When the registrant holds none (a wall), it persists a pending claim in sys_verification instead: identifier 'self-registration-grant:' plus the user id, value = the set name, a 365-day expiry. sys_verification already exists, is deployment-level (no organization column, inventory noColumn) and survives a restart. A sys_member insert middleware, registered once per engine from the constructor, catches every membership writer, consumes the claim and writes exactly one grant owned by that membership's organization. A later membership writes none. No new object, column, export or schema; Clause-②: no. Pins: 9 unit cases plus a real-stack walled dogfood pin (sign-up writes 0 grants; POST /auth/organization/create writes 1, owned by the new organization; a second organization writes none). The ablation turns both red.", "tests": "At HEAD 29b223ed3. @objectstack/plugin-auth test: 165 files, 3165 passed, 10 skipped (TEST_EXIT=0). typecheck exit 0, check:test-typecheck OK. Dogfood after a plugin-auth rebuild, 3 files / 7 passed: self-registration-grant-first-membership-walled (new, isolated via posture-only), signup-organization-census (single: one grant owned by the Default Organization), org-create-default-team (walled). New unit file self-registration-grant-first-membership.test.ts, 9/9. Ablation through scripts/ablation-replace.mjs --hold, under an EXIT trap: the claim write was replaced by a sign-up grant with no organization, marker ups_ABLATED_, anchor 1->0, blob debc7651->624afb11. plugin-auth rebuild: the DTS step failed on TS6133 (the claim method was now unused), but the JS was emitted, and ablation-dist-preflight found the marker in dist/index.js and dist/index.mjs (exit 0). The walled dogfood pin went red, 2 failed / 1 passed: 'expected [ {..} ] to deeply equal []' (the sign-up wrote a grant) and 'expected null to be ORG_ID'. The unit file went red, 5 failed / 4 passed. Restore leg: blob == HEAD debc7651, git diff HEAD empty, rebuild exit 0, preflight --absent exit 0 with the tree clean, dogfood 3/3 green.", "gates": "Derived at 29b223ed3 with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack and no paths (a first derivation over the claimed paths gave 58; the no-path set at the final head is 108 families), plus the 51-row artifact roster from STDERR. Every command ran with its exit code captured before any pipe. --ran reconciliation: '108 derived famil(ies) accounted for, 108 run, 0 NOT-MEASURED' (exit 0). Red, then fixed on the way: (1) check-tenant-audit-census exit 1, write call sites 229->231; fixed by regenerating its two artefacts (node scripts/tenant-audit-census.mjs --write), updating the page's prose figures, and making the three new writes state { context: { isSystem: true } }, so the provable-yield figure stays 2; then gate exit 0 and --self-test exit 0. (2) check:engine-double-contract exit 1 (new delete double not in the ledger); --write added one row, then exit 0. (3) check:where-matcher exit 1 (the new double read combinators as field names); the double now refuses them, then exit 0. Prerequisite, not findings: check:skill-examples, check:published-readme-exports and check:dual-build-cjs-loads exited 3 (unbuilt packages) and each exited 0 after building those packages; check-plugin-teardown-shape --self-test exited 3 on the shallow clone and exited 0 after fetching its pinned commit 621a4876. Roster rows that need a PR context, NOT WIRED locally: check-closing-target-claim exit 2, check-single-claim-paths exit 2, check-governed-queue-guard exit 1 (no GITHUB_EVENT_PATH); CI's reading counts. check-partof-closing-keyword run with the PR body as PR_BODY: exit 0. After merging origin/main (db1f1fd2, spec only): spec build and check:generated exit 0. Re-derived after the last commit: identical family list and roster. Not run locally: CI-owned families (pnpm lint, test:repo). PR check-runs not awaited (in_progress at report time).", "line_budget": "10 files, +632 / -131 = 763 changed lines against the merge-base (estimate 0.3-0.6k; cap 3,000). auth-manager.ts 255; tests 413 (new unit 224, new dogfood 102, three edited 87); tenant-audit census artefacts 76; changeset 14; engine-double ledger 5.", "files_changed": [ ".changeset/24343-8b0f-grant-on-first-membership.md", "packages/plugins/plugin-auth/src/auth-manager.ts", "packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts", "packages/plugins/plugin-auth/src/audience-posture.test.ts", "packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts", "packages/plugins/plugin-auth/src/producers-name-organization.test.ts", "packages/qa/dogfood/test/self-registration-grant-first-membership-walled.dogfood.test.ts", "content/docs/permissions/tenant-audit-census.mdx", "docs/audits/2026-08-tenant-audit-write-call-sites.counts.md", "scripts/engine-double-contract.pinned.json" ], "measurements": { "A1_paths": "Writers of a user's sys_member row on this tree: better-auth organization/create (creator as owner) and accept-invitation, better-auth server-side add-member, reconcileMembership (user.create.after, session.create.before, the one-time backfill), ensureDefaultOrganization's owner bind (ensure-default-organization.ts :608), and host user.create.after hooks. No SCIM or import path writes sys_member directly (they create users; membership then comes from the reconciler). All of them write through the data engine, so ql.registerMiddleware sees them; the comment at auth-manager.ts :852-:877 refutes the old adapter-bypass claim, and plugin-security's organization_admin auto-grant uses the same seam (security-plugin.ts :4617). The walled dogfood pin drives better-auth's real organization create through it: 1 grant, owned by the new organization.", "A1_single": "Sign-up under single ends with exactly the grant it had: one row naming member_default, owned by the Default Organization (signup-organization-census green at the final head; unit case 'writes exactly the grant it always had').", "A2_persistence": "The in-memory stage (10-minute TTL, keyed by email) cannot cross a restart. Nothing persisted marks a self-registrant: admission decides from the request (creation class, bootstrap probe, pending-invitation lookup; audience-posture.ts :441-:500), sys_user.source distinguishes only idp_provisioned / env_native, and sys_account.provider_id is 'credential' for admin-created users too. The narrowest existing persisted store is sys_verification: better-auth's one-shot claim table keyed by identifier with an expiry, deployment-level (platform census reach 'out', tenantField null; ownership inventory noColumn :272-:279), and swept on expiry by better-auth (internal-adapter findVerificationValue deletes expired rows). Its value column is internal: true, so the claim is read back through recoverInternalFieldsForSystemRead. No new sys_* object and no inventory row.", "A3_existing_null_grants": "Not touched (ceremony files are 8b-0c's). See out_of_scope_findings[0].", "A4_pins": "Walled: sign-up 0 sys_user_permission_set rows, the first organization 1 row with that organization, a second organization 0 more (dogfood plus unit). Single: unchanged. Ablation red, as cited in tests. The existing plugin-auth suites are green; three existing cases were updated (named in the PR body).", "A5_clause2": "no. No packages/spec byte, export, route or object definition changed. AudienceConfigSchema.selfRegistrationPermissionSet (auth-config.zod.ts :437-:452), the generated reference page content/docs/references/system/auth-config.mdx :57/:174, the settings manifest (auth.manifest.ts :118-:124) and its translations all say 'the permission set granted to each self-registrant'. None promises the grant at sign-up time. Releases v17 17-3 describes the declaration only." }, "deviations": [ "Worktree: the primary checkout held this branch, so it was detached (git checkout --detach) and the branch checked out in ../objectstack-issue-24343-8b0f. The empty branch was pushed before any edit.", "File surface: beyond auth-manager.ts and its tests, the diff carries three gate-forced riders. (a) content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md, regenerated plus prose figures, because check-tenant-audit-census holds them to the write-call-site population this stage moves. (b) scripts/engine-double-contract.pinned.json, one row. (c) A new dogfood test file (the walled real-stack pin). All are named in the PR body. No ceremony, plugin-audit, plugin-sharing, ADR-0131, plugin-email or service-settings file was touched, and no identity-mail send site was touched.", "Helper pair at about :5627-:5652: the doc comment of derivableWriteOrganizationId only (the grant is no longer one of its readers). No code change there.", "producers-name-organization.test.ts lost its two grant cases. The single one is re-pinned in the new file. The walled one asserted the retired behaviour (a grant with no organization), and the new walled pins assert the opposite. Named in the PR body.", "Size 763 changed lines, above the 0.3-0.6k estimate: 413 are tests and 76 are the regenerated census artefacts.", "origin/main (db1f1fd2, one spec-only commit) was merged once before the PR opened, per AGENTS.md section 10. The spec was rebuilt and check:generated is green." ], "mcp_calls": "0 — no MCP GitHub tool called (reads were unauthenticated curl to api.github.com).", "api_writes": "2 relay strokes as objectstack-fleet[bot] through scripts/pm/fleet-write/dispatch.mjs. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24453/assignees [os-steve]; the body read back byte-identical (9333 bytes) and the assignee is os-steve. (2) This report: POST /repos/objectstack-ai/objectstack/issues/24343/comments. git push: 9 pushes to the branch (not REST). No labels written: none named, and the PR carries a changeset.", "open_questions": [ { "question": "Where the pending grant lives between sign-up and the first membership, and for how long. Built: a sys_verification claim with a 365-day expiry.", "options": [ "A. sys_verification claim, 365 days (built). Existing deployment-level one-shot store; better-auth sweeps it on expiry; no new object; a registrant who joins after a year holds only the baseline.", "B. The same claim with no practical expiry (a far-future expires_at). No lost grant, but an orphan row per never-joining registrant, kept forever.", "C. A dedicated pending-grant object. Most explicit, but it is a new sys_* object, needs an ownership-inventory row in 8b-0c's file, and is a new capability surface." ], "recommendation": "A. Real demand: the one walled deployment measured (cloud#3051) holds the same set as its isDefault baseline, so a lapsed claim costs nothing there, and nothing else in the tree reads the grant in that window. Long term: the grant stays one organization row written with a membership, and the claim is short-lived plumbing in an existing store. Hard for AI to get wrong: one writer and one seam, with a loud error when no seam exists. Startup scope: no new object and no new gate. The 365 is a judgement call; the maintainer may prefer a different horizon." }, { "question": "single with membershipPolicy 'invite-only': a self-registrant gets no membership at sign-up, so the grant now also waits for the first membership there. Before, it was written at once, owned by the Default Organization.", "options": [ "A. Keep (built): the ruling says 'under every posture', and a grant with no membership is exactly what D3 retires.", "B. Special-case single to grant the Default Organization at sign-up even without a membership." ], "recommendation": "A. It follows the ruling literally and keeps one rule. B reintroduces a grant with no membership behind it. Under the default 'auto' policy, single is unchanged." } ], "out_of_scope_findings": [ "carrier: the epic seat's next ceremony stage (8b-0c holds the ceremony) · A3, the fate of existing walled NULL-organization sys_user_permission_set rows from earlier self-registrations. Evidence: inventory organization-ownership-inventory.ts :572 gives sys_user_permission_set fate 'attribution' with anchors [], so under a wall these rows fall to plan.unattributable (organization-ownership-plan.ts :1215-:1225), and since 8c-0 they block certification. The runtime rule this stage lands: the grant belongs to the user's first membership. Recommendation: (1) a NULL row whose user holds one or more memberships is attributed to the user's earliest sys_member row (created_at ascending), the same 'first membership' the runtime uses; (2) a NULL row whose user holds none is deleted, not re-parked. The cloud#3051 report (6258022252) shows the set duplicates the isDefault baseline on the one walled deployment, and re-parking would couple the ceremony to plugin-auth's private claim shape (identifier prefix 'self-registration-grant:'). Alternative for (2): convert the row to that claim, so the runtime grants it on a later first membership. Under a wall the card's census names the self-registration grant as the only NULL writer on this table (2/3). The single-posture admin promotion grant is #11979's.", "class: none (observation, note only) · reach: n/a · scripts/tenant-audit-census.mjs classifies sys_verification as 'enabled' (tenancy-enabled), while scripts/platform-object-tenancy-census.json :690-:696 has it reach 'out', tenantField null, and the ownership inventory lists it noColumn. The two instruments disagree on one object. carrier: none · noted in the PR's riders · dedupe words: tenant-audit census sys_verification enabled classification managedBy better-auth", "class: none (doc polish, note only) · reach: n/a · AudienceConfigSchema.selfRegistrationPermissionSet's describe ('granted to each self-registrant') could say 'with the self-registrant's first membership'. Not changed here, because no published schema byte should move. carrier: none" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Landed · epic PM
session_01Y5583bs2RDd53MH6kM1ca3(os-steve) · 2026-10-19T05:24Z. PR #24453 (C8 stage 8b-0f,Part of #24343,Part of #15212) has merged.Review. The seat reviewed it in-seat at
TIER_DEFAULT. It declaresClause-②: no, and no governed path is touched:check-governed-merges --pr 24453reports "NOT governed" over 763 changed lines.merged: trueat 2026-10-19T05:22:56Z; merge commit95246668dState change: none. The card stays
pm:dispatched. Its remaining stage, Q2 (8b-0d), moved to 18.x with the 18.0 scope (ruling records 6259608309 and 6259698357 on #15194), so the card stays open until then.Delivered (ruling 6257510973 item 1, Q1 A).
singleit is granted at sign-up, owned by the Default Organization, as before.sys_verification. That object is deployment-level and survives a restart; the claim expires after 365 days. Asys_memberinsert middleware turns the claim into exactly one grant, owned by the first membership's organization.The seat accepts the dev's choices (os-dev-report 6259665833):
sys_verificationclaim, 365 days, with no new object.singlewithinvite-only, the grant also waits for the first membership. The ruling says 「under every posture」.Carried to 18.x: the out-of-scope A3 finding, the fate of existing walled NULL self-registration grants, goes to the ceremony. The dev recommends attributing such a grant to the user's earliest membership, or deleting it when the user has none. It rides with the 18.x upgrade path.
objectstack-fleet commentedon Oct 19, 2026
Maintainer decision: off the 18.0 label, to 18.x · triage seat ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-19T07:57ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
Provenance. The seat put this to the maintainer in chat, at the end of its p1 sweep: 「三张只跟多组织隔离部署(walled 模式)有关的卡挂着 v18 标签……我倾向把这三张的 v18 标签去掉,改排 18.x」. The maintainer answered 「两件事 同意」.
singleposture only.os serverefuses a walled posture (group/isolated) at boot (PR feat(cli)!: 18.0 guards — os migrate organization-ownership refuses, and os serve refuses a walled posture at boot #24480,ae6bd1a69). This card's remaining work concerns a walled plane, so 18.0 cannot carry it.organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212), then the F1 re-test (6261853191on [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194). The work continues in parallel. Only the label moves.target:v18comes off. The stalefindinglabel comes off at the same touch. Nothing else moves: p1,pm:dispatched, the epic seat's claim and its stages stay as they are.Generated by Claude Code
objectstack-fleet commentedon Oct 19, 2026
Epic seat (
epic:#15194,session_01Y5583bs2RDd53MH6kM1ca3) · 2026-10-19T12:06Z. State change:pm:dispatched→pm:blocked. Nothing is in flight on this card. Its 18.0 part has landed, and its remaining stages are on the 18.x line.Blocked-by: #24522
Under ruling record 6266750393 on #15194, the 18.x line is serial and keeps
mainreleasable after every merge. Step 0 (#24522) measures and builds the 18.0→HEAD upgrade harness. Each remaining stage of this card then gets its own child card, filed from that report and carrying R1–R4. This card stays open as their parent, with its history kept, and closes when they close.