Skip to main content
Amended 2026-11-01 by 0513 (STA-1890). The clause of decision 1 that reads “Permissions, Permission and Risk stay in @stackra/iam” is superseded: Risk and Permission move to @stackra/contracts, and the Permissions kind base, registerPermissions, permissionKey, PERMISSION_REGISTRY and PermissionRegistration move to @stackra/container. @stackra/iam re-exports all of them and keeps evaluation. Rejected option B is reconsidered for contracts and container only; moving to @stackra/actions stays rejected. The other decisions stand.
Accepted 2026-10-28 by the owner (STA-1368, batch stackra-211). It replaces the proposal of 2026-10-27 (stackra-210), which offered option A or B and recommended B. Reserved number: 0462, the next free after 0461 on every branch and worktree of stackra/core on 2026-10-27.

Context

@stackra/segments declares one action, segments.segment.recompute@1 (RecomputeSegmentsAction), the hourly membership recompute. Its only actor is ActorKind.Job. gen refused it with permission_unknown, because no Permissions class declared segments.segment.recompute. Segments could not declare one: the Permissions base lives in @stackra/iam, and @stackra/iam depended on @stackra/segments for one type, SegmentRef, in its Audience union (ScopeRef | SegmentRef | RoleRef). The import would have closed a package cycle, which turbo refuses (BUILDER-RULES rule 6c). The owner asked on 2026-10-27 to settle this in the ADRs and to find every other case like it. The stackra-210 audit (notes/handover/batches/permission-and-exemption-audit.md) read core at 4a12b67, OS main at 72f073d8 and Commerce commerce/wave-a at 543d625:
  • Of 689 actions, 2 declare no permission. One is RecomputeSegments, the only one where a cycle is the cause. The other is OS traffic.hit.record, a public anonymous counter whose authors chose no permission; it is not a job.
  • 66 actions have only Job or System actors. 65 declare a permission: 25 system-held (Permission.Lx.system(), “held only by the platform; no role may grant it”) and 40 grantable.
  • The iam-to-segments edge was that one type import.
  • The same layering shape, a low package that cannot use a kind base above it, occurs in 9 more classes and one generated file: 7 @stackra/contracts drivers that cannot call Driver(port), the @stackra/config and @stackra/container errors that cannot extend Refusal, and the @stackra/testing test setup. Lint accepts most of these through path exemptions in stackra/base-matches-suffix; gen refuses all of them. The audit finds 8 classes of exemption where the two disagree (STA-1365 items 1 to 4, 8 and 9).
The owner’s decision of 2026-10-28, in their words: “segment is a business level, not a permission level”, and “I want a segment to be like … an add-on”.

Decision

  1. IAM is permission-only and knows no segments; segment targeting is business logic in the rules engine; segments is an add-on with its own permissions. (Owner, 2026-10-28.)
    • IAM. An audience of a deny, visibility or field rule is a scope node or a role: Audience = ScopeRef | RoleRef. Audience$ refuses { kind: "segment" }. PolicySnapshot carries no segment memberships, and the decision point and the visibility filter match no segment. @stackra/iam does not depend on @stackra/segments. Permissions, Permission and Risk stay in @stackra/iam; nothing moves to @stackra/actions.
    • Rules. A rule that targets a segment (“VIP customers cannot cancel an order”) is a business rule, a @stackra/rules condition on a fact: { field: "segments.ids", operator: "includes", value: "seg_…" }. The engine gains one small port, FactProvider (source, factsOf(subject)), and factsFrom(providers, subject), which gathers each bound provider’s facts under its source key. It completes the existing registration hook, Rules.register (0455), which already lets a later package add a source’s description. @stackra/rules never depends on an add-on and still loads no facts in evaluate.
    • Segments. @stackra/segments is an optional add-on above @stackra/iam and @stackra/rules. It depends on both; neither depends on it. It declares SegmentPermissions, whose recompute is system-held (Permission.L1.system()), so RecomputeSegmentsAction derives segments.segment.recompute and gen’s permission_unknown passes. Every action segments adds later declares its permission there. It supplies the SEGMENT_FACTS source and SegmentFactProviderDriver, the rules FactProvider driver that reads materialised membership.
    • Every action has a permission. gen’s permission_unknown keeps refusing an action without one, whatever its actors. A job’s permission is system-held. The OS traffic.hit.record case stays a product refusal and takes a Permission.L0 static.
    Rejected options:
  2. One table of layering exemptions, honoured by every kind check. gen (kind_unregistered_base, kind_suffix_mismatch, token_not_a_class, event_base_abstract, generated_dependency_missing, permission_unknown) and lint (stackra/base-matches-suffix) read the same rows. Each row names the placement test and why it holds. A placement that matches no row gets the ordinary kind rule (ADR 0294 R3) in both checks. A new row is added only by amending this ADR. E5 and E6 replace the oxlint-disable-next-line stackra/base-matches-suffix comments in those two files: the row is the reason, so the disable comments go once lint reads the table. E2 and E3 cover all 19 .error.ts classes that extend Error today, so narrowing lint from “any class whose name ends in Error” to these two rows breaks nothing. The proposal’s row E9 (a Job- or System-only action with no permission) existed only for option A, which is rejected; it is not added.
  3. Amendments. This record amends the pages below; they are not edited, and this list is their amendment note:
    • 0333 decision 2: the audience of a visibility rule is a scope node or a role, never a segment. Showing a record to a segment’s members is a business rule over segments.ids in the consumer that owns the record (decision 1 of this record), not a visibility rule. Decisions 3 to 7 of 0333 are unchanged.
    • 0332: segments is an optional add-on above @stackra/iam and @stackra/rules, never a dependency of either. In decision 5, “visibility rules” no longer read membership; business rules read it through the FactProvider driver of segments. Context’s “the visibility rules of 0333” is replaced by “business rules”. Segments declares its own permissions; its recompute job’s is system-held.
    • 0098: the planned gen check “rejects an action without a permission” holds for every action, jobs included; a job’s permission is system-held.
    • 0331 decision 4: besides the facts a consumer computes, a fact source may be supplied by an add-on through the FactProvider port and factsFrom.
    • 0294 R3 and decision 16: “a class extends exactly one kind base exported by a @stackra/* package” has the exemptions of decision 2, and no others.
    • 0003: within tier:lib, a package that a kind base’s package depends on uses the matching row of decision 2, never a new upward dependency.
    • 0232: Refusal is the error base everywhere except rows E2 and E3.
    • 0269: row E4 is the harness rule, and row E8 the generated setup of the testing package itself.
    • 0132 and 0099 are not amended: Permissions, Permission and Risk stay in @stackra/iam, and every action keeps its risk.

Consequences

  • Every action has a permission and a risk. The 40 job-only actions that use a grantable verb today stay valid; whether they should move to .system() verbs is a product clean-up, not part of this record.
  • IAM snapshots get smaller and IAM evaluates no business grouping. A rule stored with a segment audience before this record no longer parses (Audience$); an older snapshot event’s segments key is ignored. No such rule ships in core, OS or Commerce at the time of writing (the audit found segment audiences only in iam’s own tests).
  • A consumer that wants a segment-targeted rule registers SEGMENT_FACTS on its Rules and passes factsFrom(providers, subject) with its own facts. Without segments installed, the source is absent and such a rule is field_unknown at save, as any unknown field is.
  • The package graph: @stackra/segments depends on @stackra/iam and @stackra/rules; @stackra/iam depends on neither segments nor anything that reaches it; @stackra/rules does not depend on segments or iam. No cycle.
  • gen and lint read one table, so a member that passes lint no longer fails gen on placement (STA-1365 items 1 to 4, 8 and 9). The oxlint-disable comments in container.service.ts and action-pipeline.service.ts go away.
  • Someone must keep the table: a new row is an amendment of this record, never a new path pattern in one checker.
  • STA-1365 items 5 to 7 (AreaStorageDriver, ProviderDrivers<…> in documents, I18nKey) are gen reading gaps, not exemptions, and stay with STA-1365.

Enforcement

  • built — stackra-211 (STA-1368): Audience is ScopeRef | RoleRef with its exact-set type test and Audience$ test; iam’s package.json has no @stackra/segments; FactProvider and factsFrom in @stackra/rules; SegmentPermissions, SEGMENT_FACTS and SegmentFactProviderDriver in @stackra/segments. Gated by the testing session on landing.
  • planned — STA-1365: gen honours rows E1 to E8 and reads the same table as lint.
  • planned — STA-1365 or a batch filed for it: stackra/base-matches-suffix (packages/developer/coding-standard/src/core/constants/base-matches-suffix-rule.constant.ts) reads the table in place of its BELOW_CONTAINER and TESTING_ENTRY patterns and its “name ends in the kind” acceptance for errors. The home of the shared table must be a package both checks can import without a cycle (rule 6c).
  • turbo refuses a package cycle, so an edge back from iam or rules to segments fails every task.

Supersedes and sources

Supersedes: none (it replaces its own proposal of 2026-10-27). Amends 0003, 0098, 0232, 0269, 0294, 0331, 0332 and 0333, as decision 3 says. Sources: the owner’s decision of 2026-10-28 (STA-1368); the owner’s request of 2026-10-27 on STA-1365; Linear STA-1365 (items 1 to 9), STA-1366 and STA-1368; notes/handover/batches/permission-and-exemption-audit.md (stackra-210); the STA-344 lane rows in notes/handover/batches/HANDOFF.md.