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/contractsdrivers that cannot callDriver(port), the@stackra/configand@stackra/containererrors that cannot extendRefusal, and the@stackra/testingtest setup. Lint accepts most of these through path exemptions instackra/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).
Decision
-
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" }.PolicySnapshotcarries no segment memberships, and the decision point and the visibility filter match no segment.@stackra/iamdoes not depend on@stackra/segments.Permissions,PermissionandRiskstay 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/rulescondition on a fact:{ field: "segments.ids", operator: "includes", value: "seg_…" }. The engine gains one small port,FactProvider(source,factsOf(subject)), andfactsFrom(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/rulesnever depends on an add-on and still loads no facts inevaluate. - Segments.
@stackra/segmentsis an optional add-on above@stackra/iamand@stackra/rules. It depends on both; neither depends on it. It declaresSegmentPermissions, whoserecomputeis system-held (Permission.L1.system()), soRecomputeSegmentsActionderivessegments.segment.recomputeand gen’spermission_unknownpasses. Every action segments adds later declares its permission there. It supplies theSEGMENT_FACTSsource andSegmentFactProviderDriver, the rulesFactProviderdriver that reads materialised membership. - Every action has a permission. gen’s
permission_unknownkeeps refusing an action without one, whatever its actors. A job’s permission is system-held. The OStraffic.hit.recordcase stays a product refusal and takes aPermission.L0static.
- IAM. An audience of a deny, visibility or field rule is a scope node or a role:
-
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 theoxlint-disable-next-line stackra/base-matches-suffixcomments 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.tsclasses that extendErrortoday, so narrowing lint from “any class whose name ends inError” 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. -
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.idsin 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/iamand@stackra/rules, never a dependency of either. In decision 5, “visibility rules” no longer read membership; business rules read it through theFactProviderdriver 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
FactProviderport andfactsFrom. - 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:
Refusalis 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,PermissionandRiskstay in@stackra/iam, and every action keeps its risk.
- 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
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’ssegmentskey 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_FACTSon itsRulesand passesfactsFrom(providers, subject)with its own facts. Without segments installed, the source is absent and such a rule isfield_unknownat save, as any unknown field is. - The package graph:
@stackra/segmentsdepends on@stackra/iamand@stackra/rules;@stackra/iamdepends on neither segments nor anything that reaches it;@stackra/rulesdoes 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-disablecomments incontainer.service.tsandaction-pipeline.service.tsgo 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):
AudienceisScopeRef | RoleRefwith its exact-set type test andAudience$test; iam’spackage.jsonhas no@stackra/segments;FactProviderandfactsFromin@stackra/rules;SegmentPermissions,SEGMENT_FACTSandSegmentFactProviderDriverin@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 itsBELOW_CONTAINERandTESTING_ENTRYpatterns 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.