Skip to content

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

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-38-invite-only
Sep 7, 2026
Merged

Conversation

@os-sam

@os-sam os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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 live AuthManager in init() so
getMembershipPolicy() answers invite-only before AuthPlugin's ADR-0093 D6 backfill runs — the
option 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 the plugins
array of objectstack.config.ts. Plus the evidence directory and one corrected README row (below).

Why not the other routes

  • Constructing AuthPlugin in defineStack — the CLI stands down entirely when the app supplies
    one (cli/src/commands/serve.ts, the hasAuthPlugin check), so the app would inherit the secret,
    base URL, trusted origins, social providers, the admin plugin and the cookie domain to move one flag.
  • Seeding sys_settingmanagedBy: 'engine-owned'.
  • OS_SKIP_MEMBERSHIP_BACKFILL=1 — silences the backfill only; sign-up keeps auto-binding, so the
    deviation survives with its evidence removed.

The measurement

OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai npx objectstack dev --fresh -p 4621 --log-level info,
@objectstack/cli 17.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 the plugins array and
the artifact rebuilt (Runtime: 2 plugins), restored afterwards and verified byte-identical
(git hash-object back to a712287646e095fc1e37716a5efa847ac8de60ba, git diff HEAD clean). So the
only difference between the columns is the declaration. Full transcripts:
docs/evidence/issue-38/.

The backfill line

BEFORE, on both drivers:

INFO [auth] membership backfill (app:seeded) bound 82 member-less user(s) to the default organization (ADR-0093 D6) {"scanned":113,"bound":82,"skipped":31}

AFTER, on both drivers — no backfill line at all, and this instead:

INFO [ats] auth membership policy declared 'invite-only' (was 'auto') in init() — DESIGN.md §03: sign-up and the ADR-0093 D6 backfill bind nobody to the default organization. An explicit auth.membership_policy setting or OS_AUTH_MEMBERSHIP_POLICY still overrides this at kernel:ready.

Two corrections to the card, both reproduced here rather than quoted:

  1. The pass that binds is app:seeded, not kernel:ready. Both hooks are registered
    (auth-plugin.ts); the kernel:ready one runs before the seed's users exist and binds nobody.
  2. The runtime does not surface 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 reading
    and the BEFORE column is its control — which is why the ablation above is the shape of the proof.

sys_member in the data

driver how counted BEFORE AFTER
memory REST as the platform owner 113 31
sqlite read straight out of the database file 113 31

Per 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_member census is itself organization-filtered, so the counts above come from
better-sqlite3 against 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 total from GET /api/v1/data/OBJECT, each asserted hasMore == false and
len(records) == total before counting. 403 = PERMISSION_DENIED, unchanged in every column.

Driver memory — BEFORE and AFTER are identical, cell for cell:

persona employer employer_member interview offer job application candidate inquiry
admin@objectos.ai (owner) 12 30 40 23 40 200 80 8
admin@platform.example 12 30 40 23 40 200 80 8
ops@platform.example 12 30 40 23 40 200 80 8
admin@quillstone.example 1 3 10 3 5 27 70 2
talent1@quillstone.example 1 3 10 3 5 27 70 2
admin@harborline.example 1 3 6 4 5 31 69 2
candidate01@mail.example 9 403 0 1 22 1 1 403

Driver sqlite — BEFORE → AFTER, changed cells in bold:

persona employer employer_member interview offer job application candidate inquiry
admin@objectos.ai (owner) 0 → 0 0 → 0 0 → 0 0 → 0 40 200 80 8
admin@platform.example 0 → 12 0 → 30 0 → 40 0 → 23 40 200 80 8
ops@platform.example 0 → 12 0 → 30 0 → 40 0 → 23 40 200 80 8
admin@quillstone.example 1 3 10 3 5 27 70 2
talent1@quillstone.example 1 3 10 3 5 27 70 2
admin@harborline.example 1 3 6 4 5 31 69 2
candidate01@mail.example 0 → 9 403 0 0 → 1 22 1 1 403

Nobody 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: activeOrganizationId in the session of
admin@platform.example, ops@platform.example and candidate01@mail.example goes from the Default
Organization to null. That is the point of the card — a seeker belonging to no organization is the
correct 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 away
the spurious membership and admin@platform.example / ops@platform.example read all four
tenancy-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=auto on an otherwise identical boot: the app's line still prints in
init(), then the backfill binds the 82 again. The operator wins, deliberately — AuthPlugin binds the
auth settings namespace at kernel:ready, after this patch, and applies a value only when its source
is not default (isExplicit). So the order is explicit deployment setting (stored row or
OS_AUTH_* env) > this declaration > the platform default auto
, and that is the right order: this
file 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 --fresh prints the same line and leaves
sys_member unchanged; sys_setting is 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

pnpm validate   → exit 0   (Runtime: 3 plugins; the 3 approver warnings are pre-existing on main)
pnpm lint       → exit 0   (--i18n-strict; check:i18n-source self-test 7 assertions, 512/512 keys)
pnpm typecheck  → exit 0   (tsc --noEmit)

Each exit code was captured into a variable before any pipe. Nothing else runs in this repo's CI —
.github/workflows/ci.yml is exactly these three.

Reported, not fixed

  1. This route is unpinned upstream. Nothing in the framework — no app, example, template or test —
    sets membershipPolicy at author time; outside plugin-auth the identifier appears only in a doc
    comment 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 the
    settings → applyConfigPatch path — neither is the route an app can take. applyConfigPatch itself
    is 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 patching membershipPolicy) is what has
    no 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.
  2. "Inert for reads today because 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 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.example and candidate01@mail.example their reads
    of 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: no
    seeded ats_* row carries the default organization in employer_org, so nothing matched.
  3. Upstream candidate: the admin create-user endpoint ignores the policy.
    plugin-auth/src/admin-user-endpoints.ts, bindUserToSoleOrganization() calls
    reconcileMembership(engine, userId, { policy: 'auto', ... }) with the policy hard-coded; it
    never reads getMembershipPolicy(). Measured on this branch under invite-only: creating a user
    through POST /api/v1/auth/admin/create-user answers "membershipCreated": true and the new
    account'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.md lists DESIGN.md §01–§03 among the "files a code PR never touches …
without a needs-user-decision first", so this PR leaves it alone. §03's ruling is quoted verbatim in
the plugin's header instead. If you want the pointer, that is a needs-user-decision card and a
one-line follow-up — say the word.

Card citations, checked

Every line reference in the dispatch held on @objectstack/cli 17.3.0 and on the objectstack
checkout: the ['auto', 'invite-only'] vocabulary, getMembershipPolicy() reading
this.config.membershipPolicy ?? 'auto', applyConfigPatch being public, ctx.registerService('auth', …)
inside AuthPlugin.init(), the kernel:ready backfill hook, the kernel's init → start → kernel:ready
phases, serve.ts standing down on an app-supplied AuthPlugin, and the isExplicit settings gate. One
nit: at reconcile-membership.ts:304 the branch is the invalid-policy refusal; the
if (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

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

os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

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 --database-driver memory. That is a large enough claim that I would not merge it on a report, so I ran two sqlite servers side by side — origin/main on 4641, this branch on 4651, same driver, same seed, same recipe.

The four tenancy-scoped objects, by persona, on sqlite

persona employer employer_member interview offer
admin@objectos.ai (owner) 0 → 0 0 → 0 0 → 0 0 → 0
admin@platform.example 0 → 12 0 → 30 0 → 40 0 → 23
ops@platform.example 0 → 12 0 → 30 0 → 40 0 → 23
admin@quillstone.example 1 → 1 3 → 3 10 → 10 3 → 3
admin@harborline.example 1 → 1 3 → 3 6 → 6 4 → 4
candidate01@mail.example 0 → 9 403 → 403 0 → 0 0 → 1

ats_job 40, ats_application 200, ats_inquiry 8 for the platform personas and 5 / 27 / 2 and 5 / 31 / 2 for the two employers — identical in both columns. Nobody lost a read; three personas gained one. The 403s on the seeker are PERMISSION_DENIED and unchanged, which is the right kind of unchanged.

And in the data, read straight out of the two database files:

BASE    sys_member = 113   largest org: org_mtro0kyf5nst4x3y (the Default Organization) with 83
BRANCH  sys_member =  31   largest org: an employer, with 3 — the Default Organization is down to 1

31 = the 30 seeded employer-staff memberships + the platform owner. The 82 are gone. sys_user is 113 in both, so nothing was deleted — only the spurious bindings.

The boot lines match too: bound 82 member-less user(s) on base, and on the branch the declaration line with no backfill line at all.

What that means for #39, stated carefully

The gains are #39's symptom disappearing, and they land exactly where that card hypothesised: the seeded rows carry org_ats_* while every platform session's active organization was the Default Organization. Remove the spurious membership and the sessions carry no organization, so the scope stops excluding them.

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

  • The pass that binds is app:seeded, not kernel:ready. I saw the same line.
  • The runtime does not surface reason: 'policy' — the backfill only logs when bound > 0, so "0 bound" has no line. That is why the ablation is the right shape of proof: absence of a line is only evidence against a control that produces it.

Using the committed tree minus the plugin as the BEFORE, rather than origin/main, is better methodology than I asked for — it isolates the declaration from every other difference — and proving the mutation landed and was restored (git hash-object both ways, git diff HEAD clean, Runtime: 2 plugins vs 3) is the part that makes an ablation trustworthy.

The plugin itself

The 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 getMembershipPolicy(), the same accessor the backfill reads, and fails the boot rather than logging and continuing into a bulk bind whose rows outlive the process. For a seam that is unpinned upstream, an app-side assertion that fails loudly is exactly the right compensation — and it is the difference between a declaration and a wish, which is the defect class this repository keeps closing.

Precedence is right and rightly explained: explicit deployment setting > this declaration > platform default. An operator who sets auth.membership_policy has said something more specific than the app can, and isExplicit is what makes that true rather than accidental.

Your open question — take A

You left DESIGN.md §03 alone because AGENTS.md lists §01–§03 among the files a code PR never touches without a needs-user-decision first, and that conflicted with my dispatch. You were right and my dispatch was wrong to suggest it. AGENTS.md wins over a dispatch, and noticing the conflict instead of quietly resolving it in either direction is the behaviour I want. Option A: §03's ruling is already quoted verbatim in the plugin header, and a "where enforced" line is exactly the sort of edit that looks harmless and then accretes. No follow-up card.

I will file the admin-user-endpoints.ts finding upstream myself — you were right to hold it, since I reserved that decision.

Merging.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 7, 2026 20:02
@os-sam
os-sam merged commit c3c6526 into main Sep 7, 2026
1 check passed
os-sam pushed a commit that referenced this pull request Sep 8, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants