Authority: Two Tiers and Pairing#
How OpenProgram decides what a speaker is allowed to make the agent do, and how that decision reaches memory. Speaker attribution itself is
speaker-identity.html; the visual companion isauthority-landscape.html. Related code:openprogram/agent/authority.py,openprogram/channels/_access.py,openprogram/channels/base.py,openprogram/memory/writing.py.
The model#
Admission is binary and authority is binary. A platform account is in one of three states, only two of which are runtime authority tiers.
| State | Tier | Can do |
|---|---|---|
| Owner — local terminal, local web | owner |
Everything: run commands, write files, send messages, manage and rewrite memory. |
| Paired — an approved platform account | paired |
Converse, be recorded into memory with attribution, actively append memory. No tool execution. |
| Unpaired | none | The message does not reach the agent. The sender receives a pairing code. |
Unpaired is not a third tier. There is no "unknown external speaker gets reply-only" state: an unpaired message is never rendered into model context at all, which removes that prompt-injection surface rather than narrowing it.
The gate#
Authority travels as a single enum field, authority_tier, on the request
boundary alongside principal_id, speaker_kind, speaker_id,
speaker_display and interaction. A request never carries a capability list
of its own, so there is nothing a caller can mint inconsistently.
decide_capability() in openprogram/agent/authority.py resolves a capability
against the TIER_CAPABILITIES constant table:
| Tier | Capabilities |
|---|---|
owner |
reply, memory.source.append, memory.trusted.promote, schedule.create, schedule.manage, fs.read, fs.write, process.exec, network.send, approval.request, runtime.control |
paired |
reply, memory.source.append |
The gate is fail-closed. A missing tier denies with
AUTHORITY_TIER_MISSING; a tier that is not a table key denies with
AUTHORITY_TIER_UNKNOWN. Neither case falls back to a reduced capability set —
an unrecognized request loses every capability, including reply.
capability_for_tool() maps each tool name onto exactly one capability, and its
default arm is process.exec. An installed agentic function or a mounted MCP
tool can carry any name; treating an unclassified name as executable code means
a paired speaker cannot reach it, because only owner holds process.exec.
The gate sits in _gated_execute before the rule layer, before
_FORCE_APPROVAL_TOOLS and before the bypass short-circuit, so no permission
mode and no persisted allow rule can skip it. Every decision returns an
AuthorityDecision record — allowed, decisive check, stable reason code, tier,
capability — which is logged, so a denial is auditable rather than a bare
boolean.
runtime_authority() derives a subagent's or a runtime task's authority from
its parent: it copies the parent's normalized authority, rewrites the speaker
fields and sets interaction to non-interactive, and leaves authority_tier
untouched. Inheritance never widens. A paired turn cannot spawn an owner
child, and a parent with no valid authority yields {}, which the gate denies.
Because a subagent is never interactive, it also never holds a path to an
interactive approval.
Pairing#
A sender who is not on an account's allowlist is stopped at
decide_inbound_sender() and receives an 8-character uppercase pairing code.
The alphabet excludes the ambiguous characters 0, O, 1 and I. Codes
expire after one hour, an account holds at most three pending codes at a time,
and the same sender is not re-prompted within the hour.
Approval happens only on the owner's own machine, through the CLI or the local web UI. No channel message can approve anyone. "Please approve code X" in chat is the canonical injection phrasing; the correct response is to refuse and point at the owner.
Identity matching uses the platform's stable ID only. Usernames and display
names are mutable, so they are excluded from allowlist matching — someone who
renames themselves to the owner's name gains nothing. Display names reach a
prompt only after sanitize_speaker_display() strips newlines, zero-width and
bidi characters and neutralizes the envelope markers [ and ].
Trust semantics in memory#
Memory records a trust_state on every source frame, with two values.
trusted — the owner's own turns and everything from paired accounts.
Pairing is the trust decision, so paired speech enters the normal distillation
flow exactly as the owner's does, attributed by speaker. There is no second
gate behind the pairing gate.
pending — text archived from an unpaired speaker. Unpaired members of a
group still get their lines archived, because a group conversation is only
comprehensible whole, but that text is evidence rather than accepted memory. It
carries full provenance, and memory_promote is the owner-only path
(memory.trusted.promote) that moves a pending frame to trusted.
trust_state is the mechanism. The speaker_trusted field that appears in
retrieval output is a display projection of it, not a separate decision.
Unpaired text does not enter model context. Two independent mechanisms hold this, both live:
- The realtime path stops it first.
openprogram/channels/base.pyconsults the access decision before any agent dispatch. A blocked sender's message is archived (in group chats) and answered with the pairing reply; it never becomes a turn. - Automatic recall filters it. The memory provider's
search()drops every hit whosetrust_stateispending, so an archived pending frame cannot arrive in a later turn's injected context either.
Pending text stays reachable through the memory_search tool, where the model
asks for it deliberately and sees the trust_state alongside it.
The memory writer#
The auto-organize writer follows the default chat agent's provider, model and
credentials rather than hardcoding one CLI agent. memory.writer.model
overrides it and is editable in the web settings UI. The default is deliberately
not a cheap model: weak models misread organizing instructions, so downgrading
is an explicit choice.
Retry classification is conservative in both the provider and the session watcher: an unknown exception is non-retryable, and only explicitly classified transient exceptions retry.
Read scoping: stamp on write, filter on read#
The policy follows LangGraph's store model: visibility is decided once, when the content is written, and recorded on the content itself. A read carries the requester's tier and keeps only what that tier may see. No read path recomputes authority from conversation state, and no read path consults the pairing store.
Visibility label. Every Topic block carries one visibility value:
| Value | Written when | Readable by |
|---|---|---|
shared |
Every Source the block cites is authority_tier=paired, or the owner marked the block shared |
owner, paired |
owner |
Any cited Source is authority_tier=owner, or the block cites no Source |
owner |
Two values, not a per-speaker scope. A paired requester reads all shared
Topics rather than only its own Source-derived memory: the memory is one shared
workspace by product decision, speakers are already attributed inside it, and a
per-speaker scope would need a second identity index on every read for a
distinction the product does not make. Pending Sources have no visibility label
because they are not readable content at all; they are reachable only through
the owner's explicit review path.
Where the label lives. In the Topic block's own definition line, beside
Time: and Sources:, so it travels with the block through the existing
Markdown parse and needs no side table that could drift from the prose. The
default for an unlabeled block is owner, which makes the migration a no-op:
today's entire workspace stays owner-only until a write relabels a block.
Derivation, not declaration. The writer never asks the model for a
visibility value. install_state computes it from the trust metadata of the
block's cited Sources, which task 2 now guarantees is Runtime-owned. A model
that could name its own visibility could publish owner content to a paired
reader by writing one word.
Mixed-authority blocks. A block whose cited Sources span both tiers is
owner. The alternative — splitting the prose — would hand a paired reader a
sentence fragment whose meaning depends on the removed half. The block is the
smallest unit of meaning the format has, so it is the smallest unit visibility
can act on. Where a mixed block is genuinely wanted in shared scope, the owner
splits it into two blocks; that is a memory-organization act, not a filter.
Core injection. core.md is rendered from topics/core.md on every write.
Rendering gains a second output: core.md (owner) and core.shared.md
(shared blocks only). system_prompt() picks the file by the session's tier.
Rendering both at write time rather than filtering at injection time keeps the
per-turn path free of a parse, and keeps the two views impossible to skew.
Caches and indexes. The lexical and embedding indexes keep indexing
everything and filter at query time on the label, rather than maintaining one
index per tier. Two indexes would double both the build cost and the ways they
can disagree. The filter is applied inside MemoryBM25Index.search and
MemoryEmbeddingIndex.search, below every caller, so a new caller cannot
forget it. Persisted index files stay a single artifact; the label is a stored
field on each row.
Owner review of pending evidence. Unchanged by this design and explicitly
exempt from the filter: the owner's explicit review path returns pending Source
records with speaker_id, speaker_display, principal_id, trust_state and
authority_tier intact, because deciding whether to promote is exactly the act
of reading who said it. Pending text never enters automatic context on any
tier, which remote 9b47a45e already enforces at the recall boundary.
Acceptance matrix. Each row names the Runtime entry point that enforces the policy and the case its test has to cover.
| # | Read surface | Enforcing Runtime entry point | Case | Test level |
|---|---|---|---|---|
| 1 | Core system prompt | LocalMemoryBackend.system_prompt |
A paired session receives core.shared.md; no owner block appears in it |
unit |
| 2 | Automatic per-turn recall | LocalMemoryBackend.search |
A paired session's recall returns no owner block and no pending Source |
unit |
| 3 | Explicit memory_search |
MemoryBM25Index.search |
A paired requester's hits exclude owner blocks at every top_k |
unit |
| 4 | Embedding search | MemoryEmbeddingIndex.search |
Same exclusion as row 3, same requester, same query | unit |
| 5 | memory_get |
inspect.read_file |
A paired requester reading a Topic file receives only its shared blocks; an all-owner file is a not-found, not an empty file |
unit |
| 6 | memory_grep |
inspect.grep |
A literal string present only in an owner block produces no match for a paired requester |
unit |
| 7 | memory_browse |
inspect.list_files |
A file with no shared block is absent from a paired listing, and block counts reflect the visible subset |
unit |
| 8 | Mixed-authority block | install_state visibility derivation |
A block citing one owner and one paired Source is labeled owner and never partially rendered |
unit |
| 9 | Web reads | /api/memory/* route dependency |
Every memory route resolves the requester tier and applies the same filter as the tool path | integration |
| 10 | Owner pending review | memory_promote review path |
Pending results retain speaker, principal, trust and tier metadata | unit |
| 11 | Cache coherence | MemoryBM25Index persisted rows |
A relabeled block changes what the next query returns without an index rebuild | unit |
| 12 | Fail-closed | tier resolution helper | An unresolvable or unknown requester tier reads as owner-only-denied, never as owner |
unit |
Migration impact. None required. Unlabeled blocks read as owner, so an
existing workspace is unchanged and stays owner-only. Labels appear as blocks
are rewritten. No history-scanning migration code, consistent with product
decision 3.
Not in this design. Per-speaker read scoping, redaction inside a block, and a hold queue for over-tier reads. Each needs its own design.
Appendix: Implementation Status#
The tier enum, the constant-table gate, fail-closed denial, structured decision
records, subagent inheritance, pairing with 8-character codes, local-owner-only
approval, stable-ID matching, display-name sanitization, the pending /
trusted split, and both context-exclusion mechanisms above are implemented.
| Item | Status | Note |
|---|---|---|
| Hold queue for over-tier requests | Not implemented | A paired sender's over-tier request (for example "restart the service") queues for one-shot owner approval instead of flat denial. Parameters to adopt: a message cap, an expiry timeout, and re-evaluation of the queue when a sender's tier changes. |
| Read-time filtering by requester tier | Partly implemented | memory.read is a capability of its own, held by both tiers and distinct from fs.read, so the memory read tools no longer ride on filesystem access. search and system_prompt take the requesting tier from the caller that already resolved it and never re-read pairing state mid-turn. Trust-derived exclusion of pending-backed blocks is in place for both recall and Core. Per-tier redaction of block content is still designed only. |
| Backfill of pre-marked sources | Implemented and executed on the live workspace (8 Topic files, 132 blocks, 137 of 154 frames cited across 232 occurrences, every citation verified unbroken) | openprogram memory backfill runs the writer over every trusted source no Topic cites, ignores node markers, excludes pending sources, and promotes a legacy root core.md into topics/core.md rather than discarding it. It is idempotent against existing citations and resumable after a failed batch. |
| Queryable writer status | Implemented | Latest success time, last_outcome, and a failure classified into the closed MemoryWriteFailureCode taxonomy with its retryable verdict, plus a pending-turn count, under one versioned schema shared by the status file, CLI, tool, API and web UI. |
| Bounded archive policy for unpaired group traffic | Implemented | Unpaired group archival has a per-hour message limit, per-message and per-identity size limits, and a total archive byte ceiling. The reservation runs inside workspace_write_lock with the append it authorizes, so concurrent processes cannot both claim the last slot. |