Membership backfill binds all 80 job seekers and both platform staff to the Default Organization: the auth membership policy is left at auto, which DESIGN.md §03 forbids - #79
Conversation
DESIGN.md §03 rules that a job seeker belongs to no organization, but
nothing in the app stated the policy, so the platform default ('auto')
applied and AuthPlugin's ADR-0093 D6 backfill bound every member-less
user to the Default Organization on every boot — 82 of them on the demo
seed (80 seekers + 2 platform staff), giving seekers a shared
organization identity inside a product whose isolation boundary IS the
organization.
The policy is an auth-plugin setting read live through
getMembershipPolicy(), not a defineStack() key, so the app states it by
patching the live AuthManager through its public applyConfigPatch()
seam. A kernel plugin registering in init() for the same phase reason as
the RLS membership resolver next to it: init() runs a whole phase before
kernel:ready, where the backfill lives. optionalDependencies makes
"AuthPlugin first" a declared ordering contract rather than an accident
of the CLI's registration order, and the patch is read back through the
same accessor the backfill reads — a patch that did not take fails the
boot instead of booting into a bulk bind.
An explicit auth.membership_policy setting or OS_AUTH_MEMBERSHIP_POLICY
still overrides this at kernel:ready, which is the right precedence: the
declaration is the app's default posture, not a lock.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
The evidence directory carries the before/after backfill lines and the seven-persona read tables on both drivers, the sqlite counts read straight out of the database file, the precedence run and the second boot over a persistent file. The README's #39 row said the four tenancy-scoped objects read empty to "the platform personas" on sqlite. Measured on this branch that is no longer true: with no Default Organization membership the admin@platform.example and ops@platform.example sessions carry no active organization and read all four (employer 0 -> 12, employer_member 0 -> 30, interview 0 -> 40, offer 0 -> 23). The platform OWNER still reads zero, because the default-org bootstrap binds that account for real. The row now says which account it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Review — the headline result is real. I booted both sides myself.This card was dispatched to stop a policy deviation. It also substantially fixes #39, which is the top row of the README's known gaps and the reason the demo tells people to run on The four tenancy-scoped objects, by persona, on sqlite
And in the data, read straight out of the two database files: 31 = the 30 seeded employer-staff memberships + the platform owner. The 82 are gone. The boot lines match too: What that means for #39, stated carefullyThe gains are #39's symptom disappearing, and they land exactly where that card hypothesised: the seeded rows carry It does not close #39. The platform owner still reads 0 / 0 / 0 / 0, because that account is a genuine member of the Default Organization by the default-org bootstrap, not by the backfill. So the defect is narrowed from "the platform personas" to "one account", and the underlying upstream behaviour (objectstack#16589) is untouched. Correcting the README row rather than deleting it is the right call, and leaving #39 itself for me to re-scope was the right boundary — I will do that now. The two corrections to the card, both reproduced here
Using the committed tree minus the plugin as the BEFORE, rather than The plugin itselfThe thing I like most is that it does not trust its own patch: if (after !== ATS_MEMBERSHIP_POLICY) {
throw new Error(`[ats] membership policy is still '${after}' after applyConfigPatch(...) — Refusing to boot: …`);
}It reads back through Precedence is right and rightly explained: explicit deployment setting > this declaration > platform default. An operator who sets Your open question — take AYou left I will file the Merging. Generated by Claude Code |
Reworks the unverified WIP into a measured change. Three defects found by reviewing it against a booted server: - The job-seeker permission set left the new mirror readable. A seeker reads the 9 verified employers and is 403 on `ats_employer_member`, so the name was NEW information for that audience — 12 contact names disclosed to 80 accounts by a fix meant for 2 reviewers. Sealed like `verification_note`. - `input.owner ?? prev.owner` treated an explicit `owner: null` as absent: clearing the contact left the previous person's name in the column (measured). Reads `previous` only when the payload omits `owner`, and clears the mirror when the pointer is cleared. - The e-mail fallback lookup was dead code resting on a false premise. The engine refuses a `Field.user` value that is not an existing `sys_user` id (`reference_not_found`, measured on an e-mail and on a bogus id) and the seeder resolves its external id before hooks run — which is why the member stamp, id-only since #22, titles all 30 seeded rows with real names. Premises re-checked against the booted app, both confirmed: no platform persona reads an employer-staff `sys_user` row (1 of 113 each — self only, tighter than the 83 measured before #38/#79), and both read all 30 `ats_employer_member` rows with names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Fixes #38
The app now states its auth membership policy at author time. A small kernel plugin,
src/security/auth-membership-policy.ts, patches the liveAuthManagerininit()sogetMembershipPolicy()answersinvite-onlybefore AuthPlugin's ADR-0093 D6 backfill runs — theoption A route from the card, as an app-owned plugin, next to the RLS membership resolver that
registers in
init()for the same phase reason.Three files: the plugin, its export from
src/security/index.ts, and one entry in thepluginsarray of
objectstack.config.ts. Plus the evidence directory and one corrected README row (below).Why not the other routes
AuthPluginindefineStack— the CLI stands down entirely when the app suppliesone (
cli/src/commands/serve.ts, thehasAuthPlugincheck), so the app would inherit the secret,base URL, trusted origins, social providers, the admin plugin and the cookie domain to move one flag.
sys_setting—managedBy: 'engine-owned'.OS_SKIP_MEMBERSHIP_BACKFILL=1— silences the backfill only; sign-up keeps auto-binding, so thedeviation survives with its evidence removed.
The measurement
OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai npx objectstack dev --fresh -p 4621 --log-level info,@objectstack/cli17.3.0, counted only after[Seeder] Seed loading complete(818 rows, 0 errored).BEFORE is not
origin/main: it is this branch with the plugin removed from thepluginsarray andthe artifact rebuilt (
Runtime: 2 plugins), restored afterwards and verified byte-identical(
git hash-objectback toa712287646e095fc1e37716a5efa847ac8de60ba,git diff HEADclean). So theonly difference between the columns is the declaration. Full transcripts:
docs/evidence/issue-38/.The backfill line
BEFORE, on both drivers:
AFTER, on both drivers — no backfill line at all, and this instead:
Two corrections to the card, both reproduced here rather than quoted:
app:seeded, notkernel:ready. Both hooks are registered(
auth-plugin.ts); thekernel:readyone runs before the seed's users exist and binds nobody.reason: 'policy'. The backfill logs only when it bound someone(
if (res.bound > 0)), so "0 bound" has no line of its own. The absence of the line is the readingand the BEFORE column is its control — which is why the ablation above is the shape of the proof.
sys_memberin the dataPer organization, AFTER (identical on both drivers): the 12 employer organizations keep all 30 seeded
staff memberships — Quillstone 3, Harborline 3, Cedarbrook 3, Brightmarket 3, Lumenvale 3, Summitridge
3, Swiftroute 2, Bluewater 2, Ironbridge 2, Pixelforge 2, Meridian 2, Orbit 2 — and the Default
Organization holds exactly 1 row, the platform owner, bound by the default-org bootstrap. BEFORE it
held 83 (that 1 plus the 82). The 82 do not exist after.
On sqlite the REST
sys_membercensus is itself organization-filtered, so the counts above come frombetter-sqlite3against the file; the REST reading (83 before, 1 after) is in the transcript too.Every persona in the README's demo-login table
Rows are
totalfromGET /api/v1/data/OBJECT, each assertedhasMore == falseandlen(records) == totalbefore counting.403=PERMISSION_DENIED, unchanged in every column.Driver memory — BEFORE and AFTER are identical, cell for cell:
admin@objectos.ai(owner)admin@platform.exampleops@platform.exampleadmin@quillstone.exampletalent1@quillstone.exampleadmin@harborline.examplecandidate01@mail.exampleDriver sqlite — BEFORE → AFTER, changed cells in bold:
admin@objectos.ai(owner)admin@platform.exampleops@platform.exampleadmin@quillstone.exampletalent1@quillstone.exampleadmin@harborline.examplecandidate01@mail.exampleNobody lost a read on either driver. The README's isolation assertion still holds exactly: Quillstone
5 jobs / 27 applications / 3 offers / 3 members / 2 inquiries, Harborline 5 / 31 / 4 / 3 / 2, on both
drivers and in both columns.
What changed besides the counts:
activeOrganizationIdin the session ofadmin@platform.example,ops@platform.exampleandcandidate01@mail.examplegoes from the DefaultOrganization to
null. That is the point of the card — a seeker belonging to no organization is thecorrect state — and it is what moves the sqlite cells.
This also un-breaks #39 for the platform personas
The bolded sqlite gains are #39, and they land the way that issue hypothesised: "the seeded rows carry
org_ats_*while every platform session's active organization is the Default Organization". Take awaythe spurious membership and
admin@platform.example/ops@platform.exampleread all fourtenancy-scoped objects on sqlite. The platform owner still reads zero, because the default-org
bootstrap binds that account for real — so #39 is narrowed to one account, not closed. The README's
known-gaps row said "the platform personas"; that sentence is now false, so this PR corrects it to name
the owner. #39 itself is left open and untouched for the maintainer to re-scope.
Precedence, and a second boot
OS_AUTH_MEMBERSHIP_POLICY=autoon an otherwise identical boot: the app's line still prints ininit(), then the backfill binds the 82 again. The operator wins, deliberately — AuthPlugin binds theauthsettings namespace atkernel:ready, after this patch, and applies a value only when its sourceis not
default(isExplicit). So the order is explicit deployment setting (stored row orOS_AUTH_*env) > this declaration > the platform defaultauto, and that is the right order: thisfile is the app's default posture, not a lock, and an operator who sets the platform setting has said
something more specific than the app can. A manifest default is a UI default and does not apply.
On a persistent sqlite file, a second boot without
--freshprints the same line and leavessys_memberunchanged;sys_settingis empty, so nothing stored is competing with the declaration.The declaration is code, so it re-applies on every boot rather than being written once.
Gates
Each exit code was captured into a variable before any pipe. Nothing else runs in this repo's CI —
.github/workflows/ci.ymlis exactly these three.Reported, not fixed
sets
membershipPolicyat author time; outsideplugin-auththe identifier appears only in a doccomment in
spec/src/system/auth-config.zod.ts. What upstream does pin is the constructor option(
new AuthPlugin({ membershipPolicy }),membership-policy-setting.test.ts) and thesettings →
applyConfigPatchpath — neither is the route an app can take.applyConfigPatchitselfis exercised by dogfood tests for other keys, so the seam will not vanish silently; the combination
used here (an app plugin resolving
getService('auth')and patchingmembershipPolicy) is what hasno test. This app's own pin is inside the plugin: the policy is read back through the same accessor
the backfill reads, and a patch that did not take fails the boot instead of booting into a bulk bind.
IN (current_user.accessible_org_ids)is the deprecated SQL-style spelling and the bridge cannot bind the variable #18" is no longer true. Employer-side RLS policies fail closed for everyone:IN (current_user.accessible_org_ids)is the deprecated SQL-style spelling and the bridge cannot bind the variable #18 is closed (Employer-side RLS policies fail closed for everyone: app-owned membership resolver republishes accessible_org_ids as current_user.employer_org_ids #44 landed the resolver),and the deviation was measurably not inert: on sqlite the shared Default Organization membership was
costing
admin@platform.example,ops@platform.exampleandcandidate01@mail.exampletheir readsof the tenancy-scoped objects — the fail-closed direction, so no grant was ever widened, but not
inert. Through the employer policies (
current_user.employer_org_ids) it was inert-but-armed: noseeded
ats_*row carries the default organization inemployer_org, so nothing matched.plugin-auth/src/admin-user-endpoints.ts,bindUserToSoleOrganization()callsreconcileMembership(engine, userId, { policy: 'auto', ... })with the policy hard-coded; itnever reads
getMembershipPolicy(). Measured on this branch underinvite-only: creating a userthrough
POST /api/v1/auth/admin/create-useranswers"membershipCreated": trueand the newaccount's session carries the Default Organization. Arguably right — an operator creating an account
is an invitation — but it is not what ADR-0093 D1's "the ONE source both membership paths read"
says, and no upstream issue covers it (searched). The demo never calls this route.
One thing I did not do
The card and the dispatch both suggest a one-line pointer in DESIGN.md §03 saying where the app now
enforces the rule.
AGENTS.mdlistsDESIGN.md§01–§03 among the "files a code PR never touches …without a
needs-user-decisionfirst", so this PR leaves it alone. §03's ruling is quoted verbatim inthe plugin's header instead. If you want the pointer, that is a
needs-user-decisioncard and aone-line follow-up — say the word.
Card citations, checked
Every line reference in the dispatch held on
@objectstack/cli17.3.0 and on theobjectstackcheckout: the
['auto', 'invite-only']vocabulary,getMembershipPolicy()readingthis.config.membershipPolicy ?? 'auto',applyConfigPatchbeing public,ctx.registerService('auth', …)inside
AuthPlugin.init(), thekernel:readybackfill hook, the kernel's init → start →kernel:readyphases,
serve.tsstanding down on an app-supplied AuthPlugin, and theisExplicitsettings gate. Onenit: at
reconcile-membership.ts:304the branch is theinvalid-policyrefusal; theif (deps.policy !== 'auto') return { ...summary, reason: 'policy' }no-op is two lines below it.🤖 Generated with Claude Code
https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Generated by Claude Code