Compass `@compass` system sender + root-supervisor first turn (SEA-1820 case-1)
Status: Draft
Problem / Intent
Section titled “Problem / Intent”The merged first-turn-delivery record removed initial_prompt and froze the
shape of case-1 (root-manager boot): the seeded root Manager’s first turn is a
Compass-authored initial Setup thread in its home channel, sent by a
reserved @compass system-sender alias — and scoped the details out to
this follow-up. Per the parent record
(docs/designs/product/compass-first-turn-delivery/design.md:299-301): “The
follow-up record owes: the reserved-alias representation, reserved-handle
validation, Setup-thread creation trigger (root-manager first
StartAgentSession), thread content/versioning, and its ledger rows.” This
record decides exactly those things — (A) the @compass sender
representation and (B) the Setup-thread first-turn flow — and nothing the
parent already froze.
Global Constraints
Section titled “Global Constraints”- Go module:
github.com/sealedsecurity/compass/go. Server-side work lands undergo/serverandgo/internal/store; no new top-level packages. - The mechanism is frozen, not re-litigated. Matt ruled (parent record
OQ-C, its DL-187 row text at
compass-first-turn-delivery/design.md:570): “The@compassreserved alias is FROZEN as the system-sender mechanism for ANY system-level message sender (not just the root-manager Setup thread), requiring reserved-handle validation at account creation; case-1 root-manager boot (a Compass-authored initial Setup thread in the manager’s home channel) uses it and is scoped OUT to follow-up SEA-1820, which owes only the sender representation + Setup flow.” This record inherits that verbatim. - No prompt field anywhere. The
initial_promptremoval is frozen by the parent record’s DL-186-equivalent row (compass-first-turn-delivery/design.md:569); nothing in this record threads a prompt through any start contract. An agent session always starts idle; its first turn is a channel message. - The deliver lane is BUILT — do not redesign it. The Runner receive arm
exists (
go/internal/runner/dispatch.go:457-472, theSessionsResponse_DeliverControlcase callingd.host.Deliver(ctx, c.DeliverControl.GetSessionId(), c.DeliverControl.GetOp())), the gateway admits it (go/internal/runner/gateway/control.go:69-70: “DeliverControl is no longer among them: it carries a definedcompass.v1.Message, so it is representable and sent”), and the Server fan-out wraps posts in the deliver op (go/internal/delivery/consumer.go:288-293,deliverOp). The Setup thread rides this lane as an ordinary posted message. - Event-gated tests only. No sleep/poll loops; tests observe bus events, store rows, or returned responses.
- Ledger discipline. Matt ruled the first-turn ledger reconciliation
(resolved OQ-1): the driver retro-lands the parent record’s four rows in
docs/designs/product/DECISIONS.mdrenumbered DL-187..190 (content verbatim, with a ledger-side mapping note) in a reconciliation PR; this record’s two rows then follow as DL-191..192 (T6), appended only after that PR lands. - The fresh-start barrier-lift is built BY THIS RECORD (T-BL), ordered
before T4. The Setup deliver’s whole premise — an idle-deliver starting
the supervisor’s first turn — depends on the agent-side replay barrier
being lifted on a fresh start. That barrier defaults CLOSED
(
packages/compass-agent/src/transport/control-source.ts:278,let replayComplete = false; a pre-barrier deliver is refused-and-counted,:361-377), and NO production code sendsAgentControl{replay_complete}today: the parent record’s T-R3 is unchecked on main (compass-first-turn-delivery/design.md:626) and only thereplayPathclassifier + the ack-receive path exist (gateway/control.go:218-226,:423-431“No production caller exists yet”). Matt ruled (resolved OQ-6) that this record ABSORBS parent T-R3 rather than waiting on the parent lane: T-BL builds the fresh-startreplay_completesend, and T4 is ordered after it.
Approach
Section titled “Approach”What exists today (grounded)
Section titled “What exists today (grounded)”The author invariant. Every message author is a real accounts row,
enforced by the schema itself:
go/internal/store/migrations/0001_init.sql:200:
author_account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE RESTRICT,Every write path routes through this FK:
go/internal/store/messages.go:91-93 (AppendMessage’s single INSERT:
INSERT INTO messages (id, topic_id, author_account_id, …)), reached from
go/internal/comms/comms.go:353-354 (PostMessage:
c.store.AppendMessage(ctx, store.Message{ AuthorAccountID: c.actorFromContext(ctx), … }) and from the agent leg
go/internal/comms/agent_caller.go:143 (PostAsAccount:
c.PostMessage(WithActor(ctx, account), connect.NewRequest(req))).
No reserved-handle guard exists. CreateUser validates only non-empty
(go/internal/store/accounts.go:14-16: if u.Handle == "" { return Account{}, fmt.Errorf("%w: user handle is required", ErrInvalidArgument) });
CreateAgent likewise (accounts.go:132-134: if a.Handle == "" { … "agent handle is required" … }); BootstrapAdmin likewise (accounts.go:63-65:
"admin handle is required"). Nothing stops a user or agent registering the
handle compass today — exactly the gap the parent record named
(compass-first-turn-delivery/design.md:283-286).
The deliver set is structurally agent-only. SubscribedAgents JOINs
agent_accounts (go/internal/store/delivery_reads.go:31-33: SELECT aa.account_id FROM channel_members cm JOIN agent_accounts aa ON aa.account_id = cm.account_id), and its doc comment states the guarantee
(delivery_reads.go:25-27): “The JOIN to agent_accounts is what scopes the
result to AGENT members: a human member has no agent_accounts row and is
excluded, so a deliver is only ever dispatched to an agent session.”
An idle deliver starts a turn. The agent-side handle:
packages/compass-agent/src/agent.ts:84-85: “message is coalesced to a
turn-end prompt: mid-turn delivers queue and flush as ONE prompt when the turn
settles; an idle deliver starts a turn at once.” The barrier that would refuse
a pre-replayComplete deliver
(packages/compass-agent/src/transport/control-source.ts:278,361-377) defaults
CLOSED (let replayComplete = false) and is lifted on a fresh start ONLY by
the fresh-start replay_complete mechanism — a RULED decision (DL-188,
compass-first-turn-delivery/design.md:571) whose implementation (parent
T-R3) is UNBUILT on main (checkbox unchecked, :626; no production sender of
AgentControl{replay_complete} exists). Matt ruled this record builds it
itself: T-BL absorbs parent T-R3 (Global Constraint 7, resolved OQ-6), and T4
is ordered after T-BL.
The seed path. seedRootSupervisor is the runner-ready-hook body
(go/server/serve.go:404: hub.SetRunnerReadyHook(func() { seedRootSupervisor(ctx, st, svc, admin.ID, seedLog) })). It find-or-creates
the supervisor (go/server/serve_seed.go:73, st.AgentByHandle(ctx, rootSupervisorHandle); create at :139-144 via st.CreateAgent) and starts
it under a fixed idempotency key (serve_seed.go:32: const seedClientRequestID = "compass-root-supervisor-seed"; the SpawnAgent call
at :103-106). An already-live supervisor returns
connect.CodeAlreadyExists and the seed exits early (:107-110); success
falls through to the completion log (:120).
Home channel. Minted at CreateAgent in the same transaction
(go/internal/store/accounts.go:188-199: the INSERT INTO channels +
INSERT INTO channel_members (channel_id, account_id, subscribed) VALUES ($1, $2, FALSE), ($1, $3, TRUE) seeding owner + agent), resolved by
homeChannel() (go/internal/comms/agent_caller.go:405-413, returning
string(acc.Agent.HomeChannelID) and failing closed for a non-agent
account).
A — @compass representation: a dedicated system account type (Matt-ruled)
Section titled “A — @compass representation: a dedicated system account type (Matt-ruled)”The parent record names the fork
(compass-first-turn-delivery/design.md:286-289): “A system sender needs its
own design: reserved-handle enforcement at account creation, whether
@compass is a real reserved account row or a sentinel author, how the UI
renders it, and how delivery treats its posts (it must never be a deliver
recipient).”
A real row, not a sentinel. The sentinel option is ruled out at the
schema: messages.author_account_id is NOT NULL REFERENCES accounts (id)
(0001_init.sql:200), so a sentinel author id cannot even be INSERTed
without weakening the FK — and beyond the schema it would special-case every
read path that resolves an author (delivery_reads.go:120,
messages.go:236/461/501/566/709 all select author_account_id for
join/render) and the UI’s author resolution
(apps/ui/src/components/ChannelView.tsx:184: const author = () => props.byId.get(props.msg.authorAccountId)). A real row reuses
AppendMessage/PostAsAccount unchanged.
Matt ruled the row’s shape (resolved OQ-2): a dedicated system account
type — a third first-class account shape alongside user and agent, not a
user_accounts subtype and not a bare accounts row. The codebase already
models account subtypes as per-kind tables joined onto accounts
(0001_init.sql:42-45 user_accounts, :70-77 agent_accounts;
types.go:109-117: Account{ …, User *UserAccount, Agent *AgentAccount }),
reconstructed in one place (accounts.go:603-606: “scanAccount reads one
joined account row … setting exactly the User or Agent subtype by which side
of the join populated”) and mapped to the wire by the accountToWire switch
with a User arm and an Agent arm and NO default (mapping.go:24-35), onto
the proto Account.kind oneof (proto/compass/v1/comms.proto:140-143:
oneof kind { UserAccount user = 10; AgentAccount agent = 11; }). The
system type mirrors that pattern end to end:
- Schema: a
system_accountssubtype table —account_id TEXT PRIMARY KEY REFERENCES accounts (id) ON DELETE RESTRICT, no payload columns; the row’s existence is the discriminator. Landed by amending0001_init.sql(the store ships one squashed pre-dogfood migration whose inline comments still carry the folded 0005/0007/0015 markers,0001_init.sql:52/64/66) — or as0002_system_accounts.sqlif a squash is not wanted (the loader applies contiguous1..N,store.go:287-293); implementer’s choice (T2). - Store:
AccountgainsSystem *SystemAccountbesideUser/Agent(types.go:109-117); a thirdscanAccountarm keyed off a scannedsystem_accountsjoin column.scanAccount(accounts.go:607-645) does one fixed-arityrow.Scan, so adding a scanned column means EVERY projection that feeds it must add the matchingLEFT JOIN system_accountsand select the column — all SIX: the four inline id/handle readsGetAccount(accounts.go:245-251),adminByHandle(:107-113),AgentByHandle(:504-510), and theReparentAgentre-read (:418-424); the sharedaccountVisibleFromWhere(:543-556, feedingListAccounts/AccountVisibleTo); and the tree projectionagentTreeProjection(agent_tree.go:14-20, feedingqueryAgents→scanAccountat:33). The tree projection keeps its INNERJOIN agent_accounts(an agent row never carries a system subtype, so the new column is always NULL there) but MUST still select it, or the fixed scan arity mismatches — a Scan panic across every core account read. (Rejected alternative: inferSystemfrom thescanAccountelse-arm — neither subtype populated ⇒ system — which adds no column and touches no projection, but reads any subtype-less or corrupt row as the PRIVILEGED system sender, a fail-open on the one account that must be positively identified; the scanned column fails closed.) - Wire: a new proto
SystemAccountmessage +system = 12case in theAccount.kindoneof (comms.proto:140-143), a thirdaccountToWirearm (mapping.go:24-35), and a proto regen (theprotomoongentarget runs the threebuf generatepasses,proto/moon.yml:54). - UI (T5, now REQUIRED): the domain
Account.kindunion is"user" | "agent"today (apps/ui/src/stub-data.ts:322-323) andadaptAccountmaps any non-agent oneof case — including unset — tokind: "user"(apps/ui/src/live/adapt.ts:116-137, the unset-case tolerance documented at:112-115), so without a client arm a system author renders as a plain user. T5 adds the"system"domain kind, theadaptAccountbranch, and a system author style inMessageRow(ChannelView.tsx:184-190,roleClass).
Added scope, stated plainly. Versus the previously-drafted user-subtype row, Matt’s direction adds a proto wire change (+ regen) and a required UI arm. That is the cost of a first-class type; the visibility win below is why it is worth it.
The invariants, re-derived under the system type:
- Row shape + seeding: one
accountsrow, handlecompass, display nameCompass, with asystem_accountssubtype row — NOT anagent_accountsrow. Seeded by an idempotentEnsureSystemAccount(ctx)store method (find-or-create by handle, mirroringBootstrapAdmin’s unique-violation-means-fetch pattern,accounts.go:78-81), called inServeimmediately after theBootstrapAdmincall (go/server/serve.go:325) — the existing bootstrap seam, not a SQL migration (no account row is seeded by migration today; startup seeding is the established convention). - Reserved-handle enforcement: unchanged —
CreateUser,CreateAgent, andBootstrapAdmingain the shared guard (T1) rejecting reserved handles withErrInvalidArgument;EnsureSystemAccount’s own insert is not routed through them, so the system row itself is mintable.BootstrapAdminmust carry the guard too because the admin handle is operator-configurable (serve.go:325:cfg.resolvedAdminHandle()). - Directory visibility — the win of the ruling: the drafted user-subtype
row was swept into every caller’s
ListAccountsby theu.account_id IS NOT NULLdisjunct (accounts.go:548— the first-class member directory). A system row hits NO broad disjunct: it is visible only through the shared-channel EXISTS clause (accounts.go:550-555), i.e. exactly the co-members of channels@compassposts into can resolve its handle (so the UI’shandleOf,apps/ui/src/comms.ts:26-31, renderscompasswhere its messages appear), and nobody else ever sees it in the directory or mention autocomplete.GetRosterexcludes it structurally — every tree read INNER-JOINsagent_accounts(agent_tree.go:11-12: “the tree is agents-only”, theJOINat:20). A distinct system type is cleanly out of BOTH surfaces, which the user-subtype could not deliver; no extra exclusion clause is added anywhere. - Never a deliver recipient — structural, not policed: unchanged in
mechanism. With no
agent_accountsrow,@compasscan never appear inSubscribedAgents(delivery_reads.go:31-33, the INNERJOIN agent_accounts) orChannelAgentMembers(same JOIN shape,delivery_reads.go:69-73), so no deliver or mention→steer is ever dispatched to it;AgentByHandlereturnsErrNotFoundfor a non-agent handle (accounts.go:496-498,518-525: “a user handle is deliberately indistinguishable from an unknown one”), so an@compassmention is a routing no-op;SpawnAgentcannot provision it (no agent row to resolve). The absent agent subtype IS the guarantee — pinned by T3’s guard tests. - Not authenticatable: no token is issued for the row in the normal flow
(token minting is an explicit admin action), and the reserved-handle guard
prevents anyone re-registering the handle. One residual surface:
IssueToken(service.go:414-439) mints a bearer for ANY existing account id, so T2 adds a one-lineIssueTokenrefusal for the system account — structural, not policy-by-omission (the admin-only door already gates it — defense in depth). Server-internal posting attributes to it viaPostAsAccount(ctx, compassID, req)— the sameWithActorpath an agent post takes (agent_caller.go:131-148).
Finer sub-fork, noted for Matt’s PR review (not re-opened). The wire
change could be avoided with a lighter discriminator: leave the
Account.kind oneof UNSET for a system account and let clients key off the
handle — adaptAccount already tolerates an unset case (adapt.ts:112-115).
Recommended: the full system = 12 proto case. The unset-oneof shape is
documented client-side as the MALFORMED-row fallback (“a single bad row
never blanks the whole roster”), so shipping it deliberately would repurpose
an error path as a contract, and roleClass would still style system posts
as user with no principled discriminator. The alternative is recorded here
only so the trade is visible at review.
B — the Setup-thread first-turn flow
Section titled “B — the Setup-thread first-turn flow”Trigger. A new postSetupThread(ctx, comms, st, supervisor) step in
serve_seed.go, fired from seedRootSupervisor on BOTH arms that leave the
supervisor up: after a successful SpawnAgent (the fall-through to the
completion log, serve_seed.go:116-120) AND on the
connect.CodeAlreadyExists reject-on-live arm (:107-110) — the latter
repairs a prior boot that crashed between Start and post. Both arms are safe
because the post is idempotent (below).
Idempotency. The post carries a supervisor-scoped
client_request_id = "compass-root-supervisor-setup-<supervisorID>" (the
prefix mirrors seedClientRequestID, serve_seed.go:28-32; the scope is
OQ-7’s folded recommendation, built by T4). The store dedups on the
(author_account_id, client_request_id) partial unique index
(0001_init.sql:222-224) via AppendMessage’s ON CONFLICT … DO NOTHING
(messages.go:91-96), and a deduped retry returns inserted=false, which
suppresses the MessagePosted fan-out (comms.go:363-368: “Publish only on
a genuine insert”) — so a re-fired seed posts no duplicate for the SAME
supervisor and triggers no duplicate deliver, while a RECREATED supervisor
(a new account id) yields a fresh key and a fresh post.
Channel + membership. The thread posts into the supervisor’s home channel
(supervisor.Agent.HomeChannelID, already in hand from the seed’s
find-or-create; the generic resolver is homeChannel(),
agent_caller.go:405-413). AppendMessage gates on channel membership
(messages.go:57: requireChannelMember(ctx, tx, m.AuthorAccountID, ChannelID(channelID)), backed by go/internal/store/authz.go:21-24), so the
seed first ensures @compass is a member of that channel via the new
idempotent st.EnsureChannelMember (T4; ON CONFLICT DO NOTHING,
subscribed = FALSE, mirroring the coordination-hook insert,
coordination.go:273-277). Membership does not make it a deliver
recipient (agent-only JOIN, above), and no delivery cursor is seeded
(cursors are per-agent).
Sender. comms.PostAsAccount(ctx, compassAccountID, req) with the
explicit home-channel id (never the empty-channel default, which resolves the
ACTOR’s home channel — agent_caller.go:127-128 — and @compass has none),
topic name Setup, and the supervisor-scoped client_request_id (above).
This reuses the whole human/agent write path: D9 authz, idempotency,
MessagePosted fan-out.
Content + versioning. The thread body is an in-repo go:embed markdown
asset (go/server/setup_thread.md), versioned with the server binary. The
root Manager’s block-0 is already applied via its role
(serve_seed.go:19-21: role manager “selects config/prompts/manager/
SYSTEM.md as the container’s block-0 prompt (SEA-1732)”), so the Setup thread
is the first-TURN driver, not a system prompt: it opens the Setup flow Matt
described verbatim (compass-first-turn-delivery/design.md:30-38 — ask the
user what repos/projects, set up the tree/devenv shells). Content changes
ride server releases and affect fresh installs only: the supervisor-scoped
key is content-invariant (same supervisor → same key), so an already-seeded
supervisor never re-posts on a content revision (a versioned-key re-post is
a named future seam, deliberately not built — OQ-4).
Delivery ride (built, cited, not redesigned). The post’s MessagePosted
reaches the delivery consumer; SubscribedAgents resolves the supervisor via
the home-channel disjunct (delivery_reads.go:36: cm.subscribed OR cm.channel_id = aa.home_channel_id OR ch.mandatory_subscription) with the
author excluded (:37); @compass has no agent row, so the author-split
settle gate treats the post as human-timed (dispatched at post, per the
consumer’s contract, consumer.go:5-9); the op is wrapped by deliverOp
(consumer.go:288-293), dispatched through the Runner’s DeliverControl arm
(dispatch.go:457-472), admitted by representable
(control.go:191-195), and lands on the idle supervisor, which “starts a
turn at once” (agent.ts:84-85). On the happy path promoteSession (and its
start-sweep, which finds nothing) runs inside SpawnAgent BEFORE
postSetupThread, so the post lands against a bound session and rides live
dispatch. If the post instead lands before the session is live-bound, the
session-start sweep (consumer.go:99-105, startEvent: “sweeps the
freshly-live session’s owed messages”) converges it — the same
live-dispatch-OR-sweep convergence the parent record proved for case 2
(compass-first-turn-delivery/design.md:262-268). Caveat: sweep convergence
latency is UNBOUNDED — a Runner-side async refusal (e.g. the memo-join
dead-session case the seed documents, serve_seed.go:41-52) leaves the cursor
unadvanced and the message waits for the NEXT session-start edge
(restart/re-enroll). All of this presumes the fresh-start barrier is lifted
(the T-BL barrier lift, Global Constraint 7) — that is the gating
precondition, not this caveat.
Acceptance (SEA-1820 case-1). The seeded supervisor starts idle; the
@compass Setup thread appears in its home channel; its first turn starts
from that deliver; no prompt field is threaded anywhere.
The ledger discrepancy (grounded)
Section titled “The ledger discrepancy (grounded)”The parent record MERGED and proposed four rows as “DL-186..189” under a new
## First-turn delivery section
(compass-first-turn-delivery/design.md:560-573). On main today:
DECISIONS.mdhas NO## First-turn deliverysection and no occurrence ofDL-187,DL-188, orDL-189(verified by search this session).- The number DL-186 is already consumed by an UNRELATED decision:
DECISIONS.md:181is “Pre-dogfood proto wire-compat is stripped: allreservedmarkers removed across compass/v1 …” — not theinitial_promptremoval the parent record drafted under that number. - The true highest row on main is therefore DL-186 (the proto wire-compat strip; DL-183/184/185 precede it, no DL-19x exists).
So the parent record’s four first-turn rows never landed, and their drafted
numbers are stale (collided). Matt ruled the reconciliation (resolved OQ-1):
the driver retro-lands the four parent rows renumbered DL-187..190, content
verbatim, with a ledger-side mapping note (“drafted as DL-186..189 in
compass-first-turn-delivery/design.md; renumbered on landing — DL-186 was
consumed by the proto-strip decision”), never editing the merged parent
record’s frozen prose. This record’s own two rows (the @compass
representation decision and the Setup-thread flow decision) then follow as
DL-191..192 (T6).
Ordered; each task carries its own red-green cycle. T1–T3 and T4 are one server lane; T-BL is a runner-lane task this record absorbs from the parent (ordered before T4); T5 is the compass-ui lane (REQUIRED — the wire shape changes); T6 is the driver’s ledger gate.
T1 — Reserved-handle validation at account creation
Section titled “T1 — Reserved-handle validation at account creation”A shared validateHandle guard in go/internal/store rejecting reserved
handles at every public creation path. It is a POSITIVE grammar, not a bare
blocklist: require the handle to match the mention grammar
^[a-z0-9][a-z0-9._-]*$ (the server’s mentionRE, consumer.go:319, kept in
sync with apps/ui/src/comms.ts MENTION_RE) plus a reserved-name blocklist —
compass (the system handle) and the three names the server already expands as
reserved broadcast mentions, everyone/agents/users (consumer.go:326
reservedMentions, mirrored at comms-stub.ts:239), since an account
registered as one of those would shadow a live broadcast semantic. The positive
grammar subsumes a leading-@ reject AND forecloses the
whitespace/uppercase/Unicode-confusable spoof class (an unmentionable handle
like Compass\u200b that renders as Compass) in one move. Applied inside
CreateUser (accounts.go:13), CreateAgent (accounts.go:131), and
BootstrapAdmin (accounts.go:62) directly after their existing non-empty
checks (grep confirms these three INSERT INTO accounts sites,
accounts.go:26/75/148, are every creation path); NOT applied to the new
EnsureSystemAccount (T2), which mints the reserved compass row itself.
- Red: pgtest cases asserting
CreateUser/CreateAgent/BootstrapAdminwith handlecompass(andCompass,@compass,agents,everyone,users, and grammar violators — a leading-space handle and a zero-width-space spoofCompass\u200b) returnstore.ErrInvalidArgumentand write no row — fail before the guard exists. - Green: the guard; existing creation tests stay green.
Interfaces:
// go/internal/store (internal helper, called by the three creation paths)func validateHandle(handle string) error // ErrInvalidArgument on reservedExisting signatures unchanged:
func (s *Store) CreateUser(ctx context.Context, u NewUser) (Account, error)
(accounts.go:13),
func (s *Store) CreateAgent(ctx context.Context, ownerUserID AccountID, a NewAgent) (Account, error)
(accounts.go:131),
func (s *Store) BootstrapAdmin(ctx context.Context, u NewUser) (Account, error)
(accounts.go:62).
T2 — the system account type + EnsureSystemAccount
Section titled “T2 — the system account type + EnsureSystemAccount”The type itself: the system_accounts subtype table (§A, schema bullet),
the store SystemAccount subtype + third scanAccount arm + the
LEFT JOIN system_accounts added to ALL SIX scanAccount feeders (§A,
Store bullet — the four inline id/handle reads, accountVisibleFromWhere,
and agentTreeProjection; accounts.go:603-645 is the shared scan),
the proto system = 12 oneof case + regen (comms.proto:140-143,
proto/moon.yml:54), and the third accountToWire arm (mapping.go:24-35).
Then the seed: an idempotent find-or-create of the reserved account (handle
compass, display name Compass, system_accounts subtype), mirroring
BootstrapAdmin’s unique-violation-means-fetch shape (accounts.go:74-81).
Called in Serve directly after the BootstrapAdmin call
(go/server/serve.go:325); its returned id is threaded to the seed (T4). A
pre-existing compass row of the wrong shape (e.g. a user or agent row from
a pre-guard database) is ErrConflict and fails startup — never silent
adoption, mirroring adminByHandle’s posture (accounts.go:101-104).
Because the system account is now resolvable as a first-class type, T2 also
adds a one-line IssueToken (go/server/service.go:414-439) refusal for the
system account id (identified via the scanned System subtype), so
@compass is structurally not authenticatable — a residual on the
admin-only token-minting door, closed here rather than in T1 because the
refusal depends on the system account existing and being resolvable (both
T2).
- Red: pgtest asserting the row exists after the call with
System != nil(andUser == nil,Agent == nil), is idempotent across a second call (same id), conflicts on a wrong-shape squatter, and round-trips throughscanAccountas the system subtype; a wire-mapping test assertingaccountToWireemits thesystemkind case; plus anIssueTokenrefusal for the seeded@compassid (now that the system account is resolvable). - Green: the migration + store type + scan/wire arms + the method + the
Servewiring.
Interfaces:
const SystemAccountHandle = "compass"type SystemAccount struct{} // empty payload: the row's existence is the discriminator// Account (types.go:109-117) gains: System *SystemAccountfunc (s *Store) EnsureSystemAccount(ctx context.Context) (Account, error)// proto/compass/v1/comms.proto — Account.kind gains:// SystemAccount system = 12;message SystemAccount {}T3 — @compass is never a deliver recipient, nor in directory/roster (guard tests, no code)
Section titled “T3 — @compass is never a deliver recipient, nor in directory/roster (guard tests, no code)”The guarantees are structural (no agent_accounts row ⇒ absent from
SubscribedAgents’ and ChannelAgentMembers’ INNER JOIN,
delivery_reads.go:31-33 and :69-73; AgentByHandle → ErrNotFound,
accounts.go:496-498; no user_accounts row ⇒ outside ListAccounts’
broad user disjunct, accounts.go:548; every roster read INNER-JOINs
agent_accounts, agent_tree.go:20). This task pins them with tests so a
future subtype change reddens:
- pgtest: with
@compassa member of a channel,SubscribedAgentsandChannelAgentMembersexclude it;AgentByHandle(ctx, "compass")isErrNotFound;ListAccountsfor a viewer sharing NO channel with it excludes it, while a home-channel co-member sees it (the EXISTS clause,accounts.go:550-555); no roster read ever returns it.
Interfaces: read-only against the T2 account; no signatures change.
T-BL — fresh-start barrier lift (absorbs parent T-R3; Matt-ruled)
Section titled “T-BL — fresh-start barrier lift (absorbs parent T-R3; Matt-ruled)”On a FRESH (non-resume) Start — the discriminator is an empty
resume_session_id (go/internal/runner/host.go:353-356: “A fresh
(non-resume) start does nothing here. The discriminator is a non-empty
resume_session_id”) — the Runner sends AgentControl{replay_complete} as
the first control op after the session’s control state is bound
(host.go:415-417: listener.BindSession(sessionID), which creates the
producer state, gateway/socket.go:266-271). The send rides the existing
producer path: SocketListener.SendControl (socket.go:290) →
controlProducer.Send, which retains until acked whether or not the agent
has subscribed yet (control.go:228-231: “It succeeds whether or not a
subscription is live: retention is what makes ‘queued until acked’ true”).
replay_complete is already classified replay-path, so it passes any
barrier and is exempt from the retention cap (control.go:218-226,
:410-412). HoldForReplay — the restart-path barrier raise whose “No
production caller exists yet” (control.go:423-431) — stays untouched: a
fresh start never raises the Runner-side barrier; what this task lifts is
the AGENT-side default-closed barrier (control-source.ts:278,
let replayComplete = false), which otherwise refuses-and-counts every
deliver forever (control-source.ts:361-377).
The TS agent side is ALREADY BUILT end to end: the source decodes
replayComplete and lifts its local barrier (control-source.ts:355-359),
CompassAgent applies it (packages/compass-agent/src/agent.ts:470-471),
and the apply-then-ack ReplayCompleteAck is emitted on the Publish spine
(control-source.ts:550-551; transport/control/ack-cursor.ts:96-105),
pinned by test (control-source.test.ts:449-476). So the absorbed work is
the Runner-side send plus the end-to-end proof — narrower than the parent
T-R3 framing, stated so the scope is honest.
Cross-lane coordination: the parent first-turn-delivery PR-A scoped this same mechanism as its T-R3. This record now owns building it; the driver coordinates lane ownership with compass-runner/compass-agent so it is built exactly once (resolved OQ-6).
- Red: a runner gateway/host test — a fresh
Startproducesreplay_completeas the FIRST op on the session’s control stream; a resumeStart(non-emptyresume_session_id) sends none. An agent-side e2e leg: a deliver sent AFTER the fresh-startreplay_completeis dispatched, not refused-and-counted (control-source.ts:373-377). - Green: the send at the fresh-start arm of
agentHost.Start.
Interfaces: none new — the send composes SocketListener.SendControl
(socket.go:290) with the existing AgentControl_ReplayComplete variant.
T4 — Setup-thread post in the seed path (trigger + idempotency)
Section titled “T4 — Setup-thread post in the seed path (trigger + idempotency)”postSetupThread posts the Setup thread as @compass into the supervisor’s
home channel, fired from seedRootSupervisor on both up-arms: after a
successful SpawnAgent (serve_seed.go:116-120) and on the
CodeAlreadyExists reject-on-live arm (serve_seed.go:107-110). Steps:
st.EnsureChannelMember(ctx, homeChannelID, compassID) — the new idempotent
membership insert (Interfaces below) — then
comms.PostAsAccount(ctx, compassID, req) with the explicit
supervisor.Agent.HomeChannelID, topic name Setup, blocks from the
embedded setup_thread.md, and a supervisor-scoped client_request_id
(compass-root-supervisor-setup-<supervisorID>, OQ-7) — deduped by the
(author_account_id, client_request_id) index (0001_init.sql:222-224)
through AppendMessage’s ON CONFLICT DO NOTHING (messages.go:91-96),
with inserted=false suppressing a duplicate MessagePosted
(comms.go:363-368). A post failure is logged, not fatal, matching the
seed’s own posture (serve_seed.go:64-65); the next ready-hook re-fire
retries it (the reject-on-live arm now reaches the post).
Idempotency-key scope (OQ-7). The key is supervisor-scoped, not a single
global fixed key. The index is (author, client_request_id) global per author
(0001_init.sql:217-221) and the author is @compass forever — so a global
key would silently suppress the Setup post for a RECREATED root supervisor
(operator deletes it, the empty-tree gate re-seeds a NEW one via
createRootSupervisor, serve_seed.go:127-160): the new post dedups against
the OLD row and the recreated Manager gets no first turn. A supervisor-scoped
key is immune and costs nothing.
Retry cadence + gating. The post-failure-is-non-fatal path retries only on
the next runner-ready-hook fire (serve.go:401-408), i.e. a Runner re-enroll —
a persistent post failure leaves the Manager’s ONLY first turn undelivered
until some restart; acceptable for dogfood, stated so it is not a surprise.
T4 is ordered after T-BL (Global Constraint 7): without the fresh-start
barrier lift, postSetupThread succeeds at the store but the deliver is
refused at the agent barrier and stranded, so a green pgtest here would
still mean no first turn end-to-end — the T4 e2e acceptance is verified
against a tree where T-BL is present.
seedRootSupervisor needs the comms poster and the @compass id: extend its
signature (it is package-private with one call site, serve.go:404).
- Red: pgtest driving the seed twice — exactly ONE Setup message exists in
the supervisor’s home channel, authored by the
@compassaccount, in topicSetup; a seed re-fire on the already-live arm posts nothing new; the first genuine insert publishes exactly oneMessagePostedon the bus (event-gated: subscribe before, assert the received event). - Green:
postSetupThread+ the two call sites + the embedded content file.
Interfaces:
// Effective key is supervisor-scoped (OQ-7): prefix + "-" + supervisorAccountIDconst setupThreadClientRequestIDPrefix = "compass-root-supervisor-setup"const setupTopicName = "Setup"
//go:embed setup_thread.mdvar setupThreadBody string
func postSetupThread(ctx context.Context, cm *comms.Comms, st *store.Store, compassID store.AccountID, supervisor store.Account, log *slog.Logger)
// seedRootSupervisor gains the poster + system account id:func seedRootSupervisor(ctx context.Context, st *store.Store, svc *service, cm *comms.Comms, adminID, compassID store.AccountID, log *slog.Logger)Consumes: func (c *Comms) PostAsAccount(ctx context.Context, account store.AccountID, req *compassv1.PostMessageRequest) (*compassv1.PostMessageResponse, error) (agent_caller.go:131-135).
Channel membership (review fold M3): the post is gated on membership
(messages.go:57, requireChannelMember), so T4 adds an idempotent store
method rather than reusing UpdateChannelMembers (channels.go:407) — that
path D9-gates on the ACTOR already being a channel member
(channels.go:414-421), a chicken-and-egg for this very insert (the seed
runs server-internal, with no naturally-authorized actor on the supervisor’s
home channel), and fires membership events + owner-transitive add logic the
seed does not want:
// go/internal/store — idempotent, unsubscribed member insert mirroring the// coordination-hook insert (coordination.go:273-277):// INSERT INTO channel_members (channel_id, account_id, subscribed)// VALUES ($1, $2, FALSE) ON CONFLICT (channel_id, account_id) DO NOTHINGfunc (s *Store) EnsureChannelMember(ctx context.Context, channelID ChannelID, accountID AccountID) errorNo delivery cursor is seeded (seedDeliveryCursor is agent-only and
self-guarding, coordination.go:265-268; @compass has no agent row).
T5 — UI system-author rendering (compass-ui lane, REQUIRED)
Section titled “T5 — UI system-author rendering (compass-ui lane, REQUIRED)”Required, not polish: T2 adds a new wire Account.kind case, which forces
client handling. Today the domain Account.kind union is "user" | "agent"
(apps/ui/src/stub-data.ts:322-323) and adaptAccount maps any non-agent
case to kind: "user" (adapt.ts:116-137), so a system author would render
as a plain user (ChannelView.tsx:184-190, roleClass). Work: add the
"system" domain kind, the adaptAccount system branch, a distinct system
author style in MessageRow, and exclude compass from mention
autocomplete beside the existing
RESERVED_MENTIONS = ["everyone", "agents", "users"] (comms-stub.ts:239).
Owner: compass-ui lane; ordered after T2’s proto regen publishes the new
case (the client is forward-tolerant meanwhile, adapt.ts:112-115, so
T2–T4 do not block on it — but the record ships complete only with T5 done).
Interfaces: consumes the regenerated @compass/client wire types (the
system oneof case); UI-lane discretion within DL-148..156 constraints.
T6 — Ledger rows DL-191..192 (driver, after the reconciliation PR)
Section titled “T6 — Ledger rows DL-191..192 (driver, after the reconciliation PR)”Two new rows — (a) DL-191, the @compass representation: a dedicated
system account type (third first-class shape), startup-seeded,
reserved-handle-guarded, structurally never a deliver recipient and outside
directory/roster; (b) DL-192, the Setup-thread flow: post-seed trigger on
both up-arms, supervisor-scoped client_request_id idempotency,
home-channel carrier, embedded versioned content. Gated on the driver’s
reconciliation PR landing the four parent rows as DL-187..190 first
(resolved OQ-1); appended under the ## First-turn delivery section that PR
creates.
Ledger-impact: 2 new Active rows; no status flips (the @compass
system-sender freeze, landed DL-188, is inherited, not amended).
- T1 — reserved-handle validation (positive grammar + reserved-mention
names) in
CreateUser/CreateAgent/BootstrapAdmin+ red-green pgtest - T2 — the
systemaccount type (migration + protosystemcase + regen +scanAccount/accountToWirearms across all six feeders) +EnsureSystemAccount+Servewiring +IssueTokenrefusal + idempotency/conflict pgtest - T3 — never-a-deliver-recipient + directory/roster-exclusion guard tests
- T-BL — fresh-start
replay_completesend (Runner) + e2e barrier proof (absorbs parent T-R3; ordered before T4) - T4 —
postSetupThreadtrigger +EnsureChannelMember+ supervisor-scoped idempotency + event-gated pgtest + embedded content (ordered after T-BL) - T5 — UI system-author arm (compass-ui lane, REQUIRED — new wire kind case)
- T6 — ledger rows DL-191..192 (driver, after the reconciliation PR)
Resolved decisions
Section titled “Resolved decisions”Settled by Matt’s rulings on the draft; recorded here so the frozen record carries the decision, not the fork.
OQ-1 (resolved) — parent ledger rows: renumber + ledger-side mapping note
Section titled “OQ-1 (resolved) — parent ledger rows: renumber + ledger-side mapping note”The merged parent record directed four rows (“DL-186..189”,
compass-first-turn-delivery/design.md:560-573) into a ## First-turn delivery section of DECISIONS.md; on main no such section exists and
DL-186 is consumed by an unrelated proto wire-compat decision
(DECISIONS.md:181), so the drafted numbers are stale (see “The ledger
discrepancy”, above). Matt ruled: the driver retro-lands the four parent
rows in DECISIONS.md renumbered DL-187..190 (content verbatim) in a
reconciliation PR, WITH a mapping note in the ledger itself (“drafted as
DL-186..189 in compass-first-turn-delivery/design.md; renumbered on landing
— DL-186 was consumed by the proto-strip decision”); this record’s two rows
then follow as DL-191..192 (T6). The reconciliation NEVER edits the merged
parent record’s frozen prose (which names DL-186..189) — a reader resolves
the stale numbers through the ledger note, honoring the freeze rule (a later
change ADDS, never rewrites). Driver-owned; T6 is gated on that PR landing.
OQ-2 (resolved) — @compass is a dedicated system account type
Section titled “OQ-2 (resolved) — @compass is a dedicated system account type”The draft carried a user-subtype vs bare-row fork. Matt ruled: neither — a
dedicated system account type, a third first-class shape alongside user
and agent (§A). The user-subtype’s directory cost (swept into ListAccounts
and mention autocomplete via the broad user disjunct, accounts.go:548) and
the bare row’s unset-oneof wire shape (accountToWire, mapping.go:24-35,
has no default arm) both disappear: a system row is visible only to
shared-channel co-members (accounts.go:550-555) and ships a defined
system wire kind. Cost accepted with the ruling: a proto wire change +
regen and a required UI arm (T5). A finer sub-fork is noted for PR review in
§A: the full system = 12 proto case (recommended) vs a no-wire-change
unset-oneof discriminator (rejected as repurposing the client’s
malformed-row fallback, adapt.ts:112-115).
OQ-3 (resolved, folded into T1) — reserved namespace breadth
Section titled “OQ-3 (resolved, folded into T1) — reserved namespace breadth”Folded: T1 normatively reserves compass plus the three server
reserved-mention names everyone/agents/users (consumer.go:326),
since an account registered as one of those would shadow a live broadcast
semantic — no fork here, only a cheap squatter-foreclosure the server
semantics already demand. A broader general reserved-namespace policy (e.g.
system, admin) remains out of scope; deferred, non-load-bearing.
OQ-6 (resolved) — this record absorbs parent T-R3 (T-BL)
Section titled “OQ-6 (resolved) — this record absorbs parent T-R3 (T-BL)”The Setup-thread delivery depends on the fresh-start barrier lift: the agent
replay barrier defaults CLOSED (control-source.ts:278) and no production
code sends AgentControl{replay_complete} today (parent T-R3 unchecked,
compass-first-turn-delivery/design.md:626). The draft forked on whether
T-R3 lands parent-lane first or this record builds it. Matt ruled: this
record’s implementation absorbs T-R3 — built here as T-BL, ordered before
T4 (Global Constraint 7). Coordination implication: this record now owns
building a Runner+agent mechanism the parent first-turn-delivery PR-A also
scoped; the driver coordinates lane ownership with
compass-runner/compass-agent so it is built exactly once.
Open Questions
Section titled “Open Questions”Non-load-bearing deferrals only; each carries the folded recommendation the plan builds.
OQ-4 — Setup-thread content updates on already-seeded installs
Section titled “OQ-4 — Setup-thread content updates on already-seeded installs”The supervisor-scoped client_request_id is content-invariant: the same
supervisor id yields the same key, so a content revision never re-posts to
an already-seeded supervisor (only a RECREATED supervisor — a new account id
— gets a fresh post). Recommendation: accept for dogfood (the Setup
thread is a first-boot artifact); if re-delivery of revised content is ever
wanted, version the key (…-setup-v2-<supervisorID>) as a deliberate
product decision then — not built here.
OQ-5 — Setup-thread content sign-off
Section titled “OQ-5 — Setup-thread content sign-off”T4 embeds go/server/setup_thread.md. The flow it opens is Matt’s verbatim
shape (repos/projects interrogation, tree/devenv bring-up,
compass-first-turn-delivery/design.md:30-38), but the actual copy is
product voice and needs Matt’s review at the implementation PR. Placeholder
posture: short, imperative, ends by asking the user which repos/projects to
manage.
OQ-7 (folded recommendation) — supervisor-scoped idempotency key
Section titled “OQ-7 (folded recommendation) — supervisor-scoped idempotency key”T4 builds a supervisor-scoped Setup key
(compass-root-supervisor-setup-<supervisorAccountID>) rather than a single
global fixed key. Rationale: the idempotency index is (author, client_request_id) global per author (0001_init.sql:217-221) and the
author is @compass forever, so a global key would silently suppress the
Setup post for a RECREATED root supervisor (delete + empty-tree re-seed,
serve_seed.go:127-160) — a hole OQ-4’s content-versioning does not cover.
The scoped key is immune and costs nothing. Deferred as non-load-bearing
(the record builds the scoped key; Matt may override to a global key if
per-supervisor recreation is explicitly out of scope for dogfood).