Skip to content

refactor(core, plugin-auth, service-settings): one definition each for the admin_full_access anchor predicate and the organization-reach rule, exported from core security and called by every reader that spells them today #25315

Closed

Description

@objectstack-fleet

Filing gate ③: two findings from contract reviews, each recorded on the #15212 reconciliation ledger and outside its PR's surface.

Part of #15212.

What is on main (and on #25301's head)

  1. The admin_full_access anchor predicate is spelled twice.
  2. The organization-reach rule is spelled twice. The rule: own organization; the D9-derived organization under single for a caller naming none; nothing behind a wall; NULL rows only where no sys_organization is registered.

Wanted

  1. Measure first. Confirm that each pair states one rule, and name any difference. A real difference is a finding to report, not something to fold silently.
  2. Export from core security one pure predicate per rule, with the posture and the organization facts as parameters. Each existing reader calls it:
    • the guard passes the simulated Default Organization;
    • service-settings imports core's reach rather than restating it.
  3. No behaviour change. Every existing pin stays green, unchanged.
  4. Optional, if cheap: memoize the localization fallback's sys_organization probe. It is bounded today (only reached while the settings service fails). Drop it if it costs more than it returns.
  5. One changeset. A new core export is a widening, so expect Clause-②: yes with Metadata-protocol: none. The dev re-declares from the diff.

Acceptance

  • One definition of each rule, in core. Every reader named above calls it, and grep finds no restatement.
  • A shared-predicate unit table covers every posture and caller shape.
  • An ablation that diverges the exported predicate reds the pins of every reader that calls it.
  • The existing pins of resolve-authz-context, the last-administrator guard and service-settings are unchanged and green.
  • R1–R4 hold.

Activity

  1. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: epic seat round · 2026-10-23T00:24Z · the single-definition fold carried on the #15212 ledger (6348725929 and 6353870843), filed as this card. Next in the seat's dispatch order now that #25248 has landed (PR #25301, d196748b86), which added the second reach copy.
    Session: session_01Y5583bs2RDd53MH6kM1ca3
    Account: os-steve
    Branch: claude/issue-25315-core-security-single-definitions
    Worktree: the cloud session's own checkout (mode:cloud)
    Domain: domain:engine
    Seat: epic:#15194
    File surface:

  2. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Measurement · 2026-10-23T00:29Z · os-dev, session session_01W6m1VLhu6og8R1ACfCKJ6D, branch claude/issue-25315-core-security-single-definitions, tree objectstack-ai/objectstack@9dc1f42 (contains d196748b86)

    Step 1 of the claim (6354363886): does each pair state one rule? Read before any fold.

    Pair 1 — the admin_full_access anchor predicate

    • Core: grantAnchorsPlatformStanding(grant, { walled, defaultOrganizationId }), packages/core/src/security/resolve-authz-context.ts:944.
    • Guard: anchorOwnerTest(op, links, pending), packages/plugins/plugin-auth/src/last-admin-guard.ts:1160.

    Verdict: one rule. Walled ⇒ no grant anchors. Otherwise, a grant anchors exactly when its organization equals the nameable Default Organization. Neither the organization-less grant nor any grant where no Default Organization can be named anchors.

    Differences, axis by axis:

    Axis Core Guard Changes an answer?
    Posture source postureEnforcesWall(resolveTenancyPosture()), the REQUESTED posture the same expression no
    Default Organization memoisedDefaultOrganizationId(ql) over readDefaultOrganizationId the guard's own read, simulated over the pending write no — input form (the claim says the guard passes the simulated organization)
    When it is read an admin_full_access grant that names an organization a link that has an owner no — it only gates the read
    Failure handling tryFind throws AuthzStoreUnavailableError; the request fails scan fails closed; the write is refused no — each reader's read policy, outside the predicate
    Grant organization normalizer grantOrganizationOf: NULL, absent or blank ⇒ none; any other value ⇒ String(value) toId: a non-empty string or a number; any other type ⇒ none only on a non-string, non-number organization_id (boolean, bigint, object)

    Finding A-1 (latent, not folded silently). The two readers normalize a grant row's organization differently. Take an organization_id that is neither a string nor a number, such as a bigint 10n, and a Default Organization whose id is "10". Core anchors that grant; the guard does not count its holder. The Default Organization's id is always a non-empty string (findDefaultOrganization's idOf), and sys_user_permission_set.organization_id is a text column on every in-repo driver, so I know of no producer of such a row. Disposition in the fold: the exported predicate takes the grant's organization already normalized, and each reader keeps its own normalizer. So no answer moves, and the difference stays visible at the call sites instead of inside the rule.

    Pair 2 — the organization-reach rule

    • Core: localizationFallbackReach(ql, tenantId) plus the settingRowOrganizationOf row filter, resolve-authz-context.ts:2489.
    • service-settings: organizationReachOf / organizationWallStands, withinReach and loadScopedRows, settings-service.ts:1867, :2976 and :3006.

    Verdict: one rule, read over different facts. The rule:

    • a caller naming an organization ⇒ own (that organization);
    • else a wall ⇒ unowned;
    • else D9's decision: derived ⇒ own (that organization); no-organization-object ⇒ the NULL rows; refuse ⇒ unowned.

    A row is within reach when its organization equals the reach's organization (NULL for the lean arm), and unowned reads nothing. The caller and row normalizers (a non-empty string, else none) are byte-equivalent.

    Differences, axis by axis:

    Axis Core fallback service-settings Changes an answer?
    Posture source REQUESTED — resolveTenancyPosture() (env) IN FORCE — the tenancy service via classifyAdmissionTenancyPosture; no service ⇒ no wall yes — see R-1
    Derivation failure caught ⇒ unowned ⇒ built-in defaults not caught ⇒ the read throws yes — see R-2
    Lean NULL arm resolveGuestOrganization answers no-organization-object when no engine is wired or sys_organization is not registered the same source; a service built with no organization source also reads as no-organization-object no — input form (an unset source is the composition fact "no organization object")
    Wall test inside resolveSystemWriteOrganization (walled ⇒ refuse, no read) organizationWallStands() before the source is asked no — both answer unowned with no organization read
    sync / async async async no

    Finding R-1 (answer-changing, deliberate on the core side). Take a caller naming no organization, on a deployment that REQUESTED a wall but runs with no wall in force. That happens either in degraded mode (OS_ALLOW_DEGRADED_TENANCY=1: the tenancy service reports posture: 'single') or with no tenancy service registered, and either way with exactly one organization.

    • service-settings reads that organization's rows (own).
    • Core's fallback reads nothing (unowned) and answers UTC / en-US.

    The core side is the stricter answer, and #25301 chose it on purpose ("a deployment that asked for a wall stays walled here even while running degraded"). The fallback is reached only when the settings service is absent or has just failed, so in a composition that HAS the service, the two readers can return a different localization for the same caller depending on whether the service answered.

    Finding R-2 (answer-changing, caller policy). A failed organization read.

    • The fallback degrades to the built-in defaults.
    • The settings service lets the throw reach its caller.

    Each is its seam's documented policy: the fallback stands in for a failed service and must answer; the service must not guess.

    Disposition in the fold — no behaviour change. The exported reach rule takes the wall and the D9 decision as parameters, and each reader keeps its own source for them:

    • core: the requested posture, and catches a failure;
    • service-settings: the posture in force, and propagates a failure.

    R-1 and R-2 therefore remain exactly as they are on main, now visible as two argument choices at two call sites. Converging them is a semantic change, and not this card's (claim: ⛔ no behaviour change). Whether to converge, and which way, is the seat's call. I am reporting it, not folding it.

    Proceeding to step 2 (the fold) on these terms. Each pin stays unchanged.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-23T01:06Z. The File surface of claim 6354363886 grows by one file:

    • packages/plugins/plugin-security/src/bootstrap-platform-admin.ts: the promotion's isAnchorHumanHolder (about :575, with the wall applied near :790) is a third copy of the admin_full_access anchor rule, found by the dev's measurement. It calls core's exported predicate like the other two readers do. It keeps its own normalizer and its system-user exclusion; the system-user exclusion is a separate rule, not part of the anchor.

    Why it fits here. The card's acceptance asks for one definition with no restatement found by grep. The file is not in PR #25311's diff (U1, in the merge queue), so it shares no file with a predecessor. Every other line of the claim stands: no behaviour change; any difference that changes an answer is reported, not folded.

  4. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25315,
      "status": "done",
      "branch": "claude/issue-25315-core-security-single-definitions",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25352",
      "session": "session_01W6m1VLhu6og8R1ACfCKJ6D",
      "premise_still_valid": true,
      "summary": "Measured first (6354428826). Anchor rule: one rule, spelled three times: core grantAnchorsPlatformStanding, plugin-auth anchorOwnerTest, and plugin-security isAnchorHumanHolder. The third copy was found by the measurement and added to the surface by amendment 6354903470. Reach rule: one rule over different facts (core localization fallback, SettingsService). Folded with no behaviour change: core security now exports organizationAnchorsPlatformStanding (platform-standing-anchor.ts), and resolveOrganizationReach, organizationReachAdmits, organizationReachRowOwner, organizationIdOf, UNOWNED_ORGANIZATION_REACH and OrganizationReach (organization-reach.ts). Every reader calls them, and grep finds no restatement. Kept by design per the seat's ruling, each named in its call site's doc comment: A-1 (core vs guard grant-organization normalizer), A-2 (the promotion's raw organization_id compare), R-1 (the fallback reads the requested posture, the service the posture in force), R-2 (the fallback degrades a failed derivation, the service propagates it). The optional sys_organization probe memo was skipped: the path is bounded and outage-only. Merged origin/main 54df640b38 (#25311) at the seat's request; head 264c7ef59f.",
      "tests": "New: platform-standing-anchor.test.ts (12), organization-reach.test.ts (26), resolve-localization-fallback-row-reach.pin.test.ts (3), settings-organization-reach-memory.pin.test.ts (2). Existing pins unchanged and green: core src/security 1080/1080; service-settings 984/984; plugin-auth 3525 pass / 10 skipped; plugin-security 4442 pass / 45 skipped (post-merge). typecheck exit 0 for core, plugin-auth, service-settings and plugin-security; eslint over the changed files exit 0; full pnpm build 76/76. Ablations via scripts/ablation-replace.mjs (the anchor must hit, restore proven to the HEAD blob with git diff HEAD empty). The anchor one rebuilt core, and ablation-dist-preflight proved the marker absent from dist under mutation and present again after restore. (1) anchor, any organization's grant anchors: core 7 red, guard 6 red, promotion 5 red. (2) reach, derived read as unowned: core 3 red (1 is the fallback pin), service-settings 5 red. (3) reach, own admits NULL rows: at first only the shared table went red, so the two reader pins were added; then core 5 red (2 are the new fallback pin) and service-settings 2 red (the new memory pin). Upgrade harness upgrade-18x-harness.dogfood.test.ts 16/16 (fixture-18.0 ceremony leg + fixture-18.0.0 GA leg), at 40ac96aa and again at 264c7ef59f. R1 at 264c7ef59f: production os serve of the showcase, empty store, single: ready 9 s, health 200, sign-up 200, sign-in 200, me/localization 200 (UTC/en-US), and the first user holds platform_admin/admin_full_access. R4 at 264c7ef59f: walled production os serve (NODE_ENV=production, OS_PLATFORM_OWNER_EMAIL, invite-only, 32-byte OS_SECRET_KEY): isolated and group each ready 8 s, health 200, sign-up 200, sign-in 200, me/localization 200, and the configured owner holds platform_admin. Under isolated, PUT /api/settings/localization Asia/Tokyo 200, and the next me/localization answered Asia/Tokyo. @objectstack/organizations was added to the showcase locally for R4 only; package.json and the lockfile were restored (git status clean).",
      "gates": "node scripts/pm/dispatch-gates.mjs --commands: 74 derived, each exit code captured before any pipe (one log per command). At 40ac96aa: 74/74 exit 0 (after git fetch --unshallow and a full build). At 264c7ef59f, re-derived (12 paths vs merge base 54df640b3): 74/74 exit 0. check-governed-merges --branch: NOT governed, 0 of 12 paths, 814 changed lines (+694/-120), not Tier H/S. CI on 264c7ef59f: 44 of 45 check runs completed, each success or skipped. Required: Lint & Repo Gates success, TypeScript Type Check success, Build Core success, Governed Surface Queue Guard success, Test Core 1, 2, 4, 5 and 6 of 6 success, 3/6 in_progress at report time. Also: Check Changeset success; 18.x upgrade harness (opt-in) success; the claim and closing-keyword guards success.",
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm; the opt-in 18.x upgrade harness lane ran on the PR (success) and was also run locally (16/16).",
      "mcp_calls": "8 GitHub: mcp__github__issue_read x2 (get, get_comments), mcp__github__pull_request_read x3 (get #25301, get_check_runs x2); claude-code-remote send_message x3 to the seat. No GitHub write tool. Two REST reads via curl (comment 6354903470, #25248 comments).",
      "api_writes": "3 — fleet-write relay POST /repos/{board}/dispatches: the measurement comment 6354428826, pr_create #25352 (draft, assignee os-steve), and this os-dev-report comment. git pushes are not REST writes.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed: on the walled R4 boots (isolated and group) one non-fatal WARN '[security] audience-binding suggestion sync failed' appears at boot, from plugin-security suggested-audience-bindings.ts, which this diff does not touch. Not measured on main, so no claim that it is new.",
        "carrier: the epic seat · noted: the reach pair's row comparison (organizationReachAdmits) had no reader pin before this PR. The engine paths also filter in the query, and the settings in-memory store had no NULL-row-vs-organization case. The two pins added here close it."
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-23T03:30Z. PR #25352 merged into main as b3034eeffa, and it closes this card.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions