ADR-0057: Reserved publishers¶
Status: Accepted Date: 2026-10-12 Deciders: Alex Nodeland
Designs the values of ADR-0039's decision 4, which put reserved publishers on Workspaces, keyed by namespace, with per-type exceptions.
Context¶
Workspaces(emitted=[...]) reserves types to runs, but nothing reserves a type to one publisher (lattice #8, was reflexr #72). Any client allowed to publish could send an event that only a bridge or an integration should.
stackr RFC-0002 bridges artifactr's events into reflexr as artifactr:*, published as SourceActor(name="artifactr"), and rules act on them. One installs rules a person accepted in chat, so a forged artifactr:proposal_resolved must install nothing (phase 5). Until types can be reserved, those rules check the envelope's actor themselves.
ADR-0039 settled where the policy lives and its keys. It left three questions open: what a value names, whether emitted= folds into it, and where the check sits.
Decision¶
Workspaces(publishers={key: [actor, ...]}). A key is a namespace ("artifactr") or a qualified type ("plans:plan_approved"). A type's entry replaces its namespace's, so an exception can widen or narrow it.- A value is actors, compared by
participant.SourceActor(name="artifactr")is the publishersource:artifactr. Every run of one rule's agent shares a participant (agent:<rule>), so a type can be reserved to a rule's runs. Full equality could never match one: each run has its ownrun_id. - Anyone else is
forbidden, runs included:"artifactr:proposal_resolved events are reserved to source:artifactr". A rule's agent that emits a bridged type is as much a forgery as a client that publishes one. - A fixed check beneath
authorize, beside the refusal ofreflexr:*.publishasksauthorizeforreflexr:publishfirst, then checks the type. The hook decides who may publish at all, and the policy decides who may publish a type. grantr can't grant around it. It adds nothing toACTIONS. - Keys are checked at startup, as
events=,emitted=and rules are. A type or namespace that isn't in the workspaces'registry, orreflexr's, raisesValueError. emitted=stays. It reserves a type to whatever a run caused, which is a handle's causation, not an actor's identity. Folding it in would need a "runs" value that is not an actor. The two compose: a type in both must be published by a run of a listed publisher.
Options considered¶
What a value names¶
| Option | A rule's runs | Typed |
|---|---|---|
Actors, compared by participant (chosen) |
Yes: one participant per rule | Yes |
| Actors, compared by equality | No: each run's run_id differs |
Yes |
Participant strings ("source:artifactr") |
Yes | No: a typo is a reservation no one can meet |
Where the check sits¶
| Option | Who can override it |
|---|---|
A fixed check after authorize (chosen) |
No one: it is configuration in code, like emitted= |
A decision for authorize |
The application's hook and grantr's roles, which can't tell a forged bridge event from a real one: both come from an actor allowed to publish |
Folding emitted= in¶
| Option | Cost |
|---|---|
| Keep it apart (chosen) | Two keywords for two kinds of reservation |
A runs value in publishers |
A value that is not an actor, and a breaking change to emitted= |
Trade-off analysis¶
Reserving by participant lets one entry cover a source, a system component or a rule's runs, with typed actors as events= and emitted= take typed classes. Putting the check beneath the hook keeps the policy where ADR-0039 put it: the application's code, which alone resolves clients to actors. The cost ADR-0039 named remains. An application that forgets the bridge's entry leaves forging open, so relayr's wiring should pass it.
Consequences¶
- Easier: a bridge's namespace is reserved in one entry, and rules can trust an
artifactr:*envelope's actor without checking it themselves. - Easier: an integration's types, such as a webhook's, can be reserved to the source that receives it.
- Harder: an application must never resolve a client to a reserved source. This was true before. Now the library relies on it.
- Revisit: a library-exported entry, once relayr has one to export.
Action items¶
-
Workspaces(publishers=), checked at startup and inpublish, with tests on handles and REST. - relayr's wiring passes
publishers={"artifactr": [SourceActor(name="artifactr")]}(stackr RFC-0002, phases 3 and 5). - relayr's rules drop their own check of the envelope's actor (stackr RFC-0002, phase 5).