Skip to content

feat(approvals): cross-organization approver targeting (ADR-0105 D9) - #3824

Merged
os-zhuang merged 2 commits into
mainfrom
claude/adr-0105-group-tenancy-posture-5womi2
Jul 28, 2026
Merged

feat(approvals): cross-organization approver targeting (ADR-0105 D9)#3824
os-zhuang merged 2 commits into
mainfrom
claude/adr-0105-group-tenancy-posture-5womi2

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

ADR-0105 Phase 2 D9. Tracking: #3541. Follows D8 (#3663, #3674, #3695, objectui#2868, cloud#886).

The gap

A plant document that needs a group-side sign-off could not be expressed. One organization id decided three different things at once in openNodeRequest:

what it decided why
where the request row lives the request's own org, by definition
where its inbox index rows live same
where its approvers are looked up not the same thing

A group CFO holds her cfo position in the GROUP organization; the purchase order she signs off lives in the PLANT organization. So expandPositionUsers('cfo', <plant>) matched nobody, the slot fell back to the dead position:cfo literal, and the request routed into onEmptyApprovers. Silently.

The first test in the integration suite pins exactly that pre-D9 behaviour, so the gap this closes is a fact in the repo rather than a claim in a PR body.

The change

approvers:
  - { type: position, value: plant_manager, group: plant }
  - { type: position, value: cfo, organization: $root, group: finance }
behavior: per_group

Only the third job moves. The request row, its sys_approval_approver index rows, and its audit actions all still belong to the request's organization — asserted, because relocating them would cost the plant its own audit trail.

Four design points the ADR left open

The ADR said "an approval chain node may name a target organization". Implementing it surfaced four questions it didn't answer; the ADR text is amended in this PR with the reasoning.

1. Per approver, not per node. The node-level form cannot express the commonest group shape — one node requiring a plant manager and a group CFO in parallel (per_group). Expressing that as two serial nodes changes the semantics (parallel co-sign becomes sequential approval), so node-level distorts the model rather than merely inconveniencing the author. A node-level default is a strict special case of the per-approver form and stays addable later as sugar; the reverse is not true. It also puts the knob where resolveAs and group already live.

2. Symbols first — $root / $parent. Flow metadata is portable across environments; an organization id is data minted per deployment. A literal id in a flow is unportable by construction, and an AI author cannot know one at authoring time — so the value space must have a deployment-independent spelling for the common intents. $root (escalate to group) and $parent (division sign-off in a three-tier group) walk D6's parent_organization_id tree. A slug covers what they cannot, notably a sibling organization: a shared-services centre approving payables for every plant.

3. Legality is "shares a parent_organization_id root", not "is an ancestor" — because of that sibling case, and downward targeting too. Critically the rule reads only the organization tree, never the submitter: one flow routes identically for everyone, which is what makes a routing bug reproducible. (Bounding by the submitter's accessible_org_ids was the alternative; it would have made flow behaviour vary per submitter and blocked the main use case, since the plant submitter is usually not a member of the group org.) A deployment with no grouping metadata gets a refusal naming D6, not a silent pass.

4. Non-group postures refuse at runtime. Posture is environment configuration, so the same portable metadata may be deployed into any posture and no static check can see which — this cannot be a lint. Silently ignoring the declaration would let a groupisolated migration reroute approvals with no signal, which is an audit event, not a config detail. Refusal follows the expression-approver philosophy already in this file: a config that cannot run is a routing bug, never "condition not met".

Two silent failures made loud

  • A type with no org-scoped directory (user / field / manager / team — the last because sys_team_member carries no organization column) refuses the declaration rather than ignoring it: an author who wrote it believed it did something. New approval-approver-cross-org-unsupported lint (error) catches it at author time. The applicability table is satisfies-exhaustive, so a new ApproverType that doesn't declare whether targeting applies fails the build.

  • A targeted approver with no membership in the request's organization is dropped, with a warning naming them. The ADR says cross-org reads "are covered by D2 (membership union)" — true, but D2's union is organization_id IN accessible_org_ids and the request is stamped with the request's org, so a group-side approver reaches it only if she also holds a membership there. That is the intended group shape (the ADR-0105 acceptance dogfood already models it) but nothing enforced it. Unchecked, the failure is silent and expensive: routing succeeds, the inbox row is written, and she opens a task the wall hides. Dropping converts it into the node's existing onEmptyApprovers policy. An unreadable membership table does not drop anyone — emptying a live slate on an infrastructure hiccup is worse than routing optimistically.

Verification

plugin-approvals   12 files  305 passed   (17 resolver units + 7 service integration are new)
lint               33 files  500 passed   (4 new)
spec              259 files 6736 passed
cli                68 files  682 passed
eslint clean · check:nul-bytes · check:doc-authoring · check:role-word
check:org-identifier · check:authz-resolver  all pass
gen:schema / gen:docs / gen:api-surface regenerated and committed

The resolver tests cover the boundaries rather than the happy path: $root climbing more than one level (a one-level implementation would pass a shallower test), a sibling accepted, a foreign-group org refused, an id-instead-of-slug refused with a message saying why, a cycle in the grouping metadata terminating, and a non-group posture refusing.

Compatibility

An approver without organization takes the same path as before — same resolution, same queries, no extra reads (asserted). No schema migration; the field is optional and additive.

🤖 Generated with Claude Code

https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL


Generated by Claude Code

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 8:33am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/plugin-approvals, @objectstack/spec.

104 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals, packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang os-zhuang closed this Jul 28, 2026
@os-zhuang os-zhuang reopened this Jul 28, 2026
claude added 2 commits July 28, 2026 08:22
One organization id decided three different things at once in
`openNodeRequest`: where the request row lives, where its inbox index rows
live, and where its approvers are looked up. The first two are the request's
own organization by definition. The third is not — a group CFO holds her `cfo`
position in the GROUP organization while the purchase order she signs off
lives in the PLANT organization. `expandPositionUsers('cfo', <plant>)` matched
nobody, the slot fell back to the dead `position:cfo` literal, and a group
escalation could not be expressed at all.

An approver may now declare which organization's directory resolves it:

    - { type: position, value: plant_manager, group: plant }
    - { type: position, value: cfo, organization: $root, group: finance }

Four design points the ADR left open, settled from what the code and the
authoring model require (ADR text amended in this commit):

- PER APPROVER, not per node. The node-level form cannot express the commonest
  group shape — one node requiring a plant manager AND a group CFO in parallel.
  Splitting into serial nodes changes the semantics rather than the syntax. A
  node-level default remains addable later as sugar; the reverse is not true.

- SYMBOLS FIRST (`$root` / `$parent`). Flow metadata is portable across
  environments; an organization id is data minted per deployment, so a literal
  id in a flow is unportable by construction and an AI author cannot know one.
  The symbols walk D6's `parent_organization_id` tree with zero deployment
  knowledge. A slug covers what they cannot — notably a SIBLING organization
  (a shared-services centre approving payables for every plant).

- LEGALITY IS "SHARES A ROOT", not "is an ancestor" — the sibling case above is
  first-class. The rule reads only the organization tree, never the submitter,
  so one flow routes identically for everyone and a routing bug is reproducible.

- NON-`group` POSTURES REFUSE at runtime. Posture is environment configuration,
  so the same portable metadata deploys into any posture and no static check
  can see which; ignoring the declaration would let a group → isolated
  migration reroute approvals with no signal.

Two failure modes are made loud rather than silent: an approver type with no
org-scoped directory (`user`/`field`/`manager`/`team`) refuses the declaration
instead of ignoring it, with a new `approval-approver-cross-org-unsupported`
lint catching it at author time; and a targeted approver holding no membership
in the request's organization is dropped with a warning naming them — D2's
union wall would otherwise hide the request from someone already routed to,
leaving a task she cannot open. Dropping hands it to the node's existing
`onEmptyApprovers` policy.

An approver without `organization` is unchanged: same resolution, same
queries, no extra reads.

Tests: 17 resolver units (symbols, sibling/foreign slugs, posture, cycles,
unreadable-membership), 7 service integration (including the pre-D9 gap, the
mixed plant+group node, and that the request still belongs to the request org),
4 lint. plugin-approvals 305, lint 500, spec 6736, cli 682 all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
The drift check flagged `automation/approvals.mdx` — correctly. D9 adds an
authoring capability, and the hand-written guide is where an author (human or
AI) actually looks; a feature reachable only from the generated schema
reference is a feature most authors never find.

Adds a section covering what the reference table cannot: which value to reach
for ($root over a slug, because flow metadata is portable and organization ids
are not), and the three things the field deliberately does NOT do — it does not
move the request, it is not a route to an arbitrary tenant, and it does not
apply to approver kinds that consult no organization directory. Plus the
read-visibility precondition, which is the one way a correctly-authored
cross-org approval still ends up empty.

Also points the approver-kinds paragraph at the new section, so the fact that
only some kinds accept `organization` is discoverable from where the kinds are
listed rather than only from the lint that rejects it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL
@os-zhuang
os-zhuang force-pushed the claude/adr-0105-group-tenancy-posture-5womi2 branch from 3545221 to ce10d23 Compare July 28, 2026 08:33
@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 08:46
@os-zhuang
os-zhuang merged commit 14252d3 into main Jul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/adr-0105-group-tenancy-posture-5womi2 branch July 28, 2026 08:46
os-zhuang added a commit that referenced this pull request Jul 28, 2026
… types (#3873)

`user`, `field` and `manager` return EARLY in `resolveApproverSpec` — they name
a person outright instead of expanding a directory. D9's org resolution (#3824)
sat AFTER those returns, so an `organization` declared on one of them never
reached the check and was silently INERT.

That is precisely the behaviour ADR-0105 D9 rules out and the authoring docs
promise against ("`organization` on those is refused at runtime"). The `os lint`
rule caught it at author time, but the runtime claim was false — and a flow
stored before the lint existed, or assembled programmatically, got no signal.

Resolution moves to the top of `resolveApproverSpec`, above every early return,
so the refusal reaches all three types. The ordinary path is unchanged and
still free: with no `organization` declared the resolver returns the request's
organization without reading anything.

Why the existing tests missed it: `approver-org-scope.test.ts` calls the
resolver DIRECTLY, so it never traverses the early return, and the service-level
integration test only covered `position`. Both gaps are closed — the new case
opens a real request for each of the three directory-less types and asserts the
refusal, plus one that a directory-less approver with NO declaration is
untouched, so the guard cannot start costing the ordinary case.

Found by cloud's group-posture dogfood on a real `group` boot. The two links
that dogfood was written to check both hold: a cross-organization approver does
reach her queue through the D2 union wall, and `decide()` does accept her.


Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants