phonecore

package
v0.13.11 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: MIT Imports: 33 Imported by: 0

Documentation

Overview

Package phonecore is the gomobile-ready phone-side client logic for remote control (R-PHC): pairing, transport, command signing, and consuming the daemon journal -- all in Go, tested against itself on the build machine (ADR-007 D12). The SwiftUI shell is a thin layer over this compiled later.

This slice implements the JOURNAL-RECEIVE path (R-PHC.3/.5): open a mailbox envelope under the epoch content key, decode the journal record, and apply it to a merged session cache whose Group is taken VERBATIM from the wire (never derived on-device).

Index

Constants

View Source
const (
	// InputFrameInterval is the minimum spacing between two coalesced input frames for
	// one session: 125 ms, i.e. 8 frames/s (§6.0). 100 ms would be exactly the relay's
	// 10/s mailbox_append cap, so the first jitter would trip codeQuotaExceeded mid-lease.
	InputFrameInterval = 125 * time.Millisecond

	// MaxInputPayload caps one coalesced frame at 4 KiB (§6.0). A window that accumulated
	// without a cap would eventually produce a frame the relay refuses on size, losing the
	// whole burst at once rather than pacing it.
	MaxInputPayload = 4096

	// UndeliveredLedgerSize bounds the PB-INPUT-1 ledger.
	//
	// It is not a number this file invents: journalLogSize bounds the facade's other
	// unbounded-by-nature read model at the same 1024, for the same reason -- the DURABLE
	// model is elsewhere and this is what a screen renders, so it is bounded rather than
	// grown for the life of the process.
	//
	// The way here is ordinary, not contrived: PB-INPUT-2 refuses every keystroke until the
	// machine confirms a lease, each refusal is one entry, and autorepeat on a held key is
	// about 30 events/s -- so a minute against a dead lease is roughly 1800 entries. Worse,
	// the facade copies the whole slice on every read, so the screen that renders the problem
	// gets slower the worse the problem is.
	UndeliveredLedgerSize = 1024
)
View Source
const (
	KindUserMessage     = "user_message"
	KindAgentMessage    = "agent_message"
	KindToolRun         = "tool_run"
	KindFileChange      = "file_change"
	KindApprovalRequest = "approval_request"
	KindApprovalResolve = "approval_resolved"
	KindPlanUpdate      = "plan_update"
	KindSessionStatus   = "session_status"
)

The eight kinds of §3. A kind outside this set is skipped and costs the consumer nothing but a cursor advance (IS-COMPAT-1).

View Source
const (
	StatusInProgress = "in_progress"
	StatusCompleted  = "completed"
	StatusFailed     = "failed"
	StatusDeclined   = "declined"
)

The four statuses of §4. Three are terminal, and IS-ST-1 allows at most one of them per item_id with no record after it.

View Source
const (
	// CommandTTL is how long an ordinary signed command stays valid (§6.0, PB-TIME-1).
	CommandTTL = 1 * time.Minute
	// TakeControlTTL is §6.0's stated exception. The daemon's lease deadline is the
	// EARLIEST of the device-signed ExpiresAt, now+TTLSeconds and a 30-minute cap, so a
	// blanket 1-minute signed horizon would make the SIGNATURE the thing that ends a typing
	// session -- colliding with PB-INPUT-5's >= 60 s sustained-typing criterion. 15 minutes
	// clears that wall and stays under the cap.
	//
	// PB-INPUT-3 counts TWO walls here, not three (ADR-007 B133): §6.0's 60 s biometric
	// freshness was the third, and it is withdrawn with the requirement that owned it. The
	// signed horizon and the sustained-typing floor are what remain, and neither moved.
	//
	// ADR-017 T7 ADOPTS THIS NUMBER for the terminal control generation's horizon rather
	// than inventing a second one (see TerminalControlTTL in terminalroute.go), precisely
	// so the system has ONE fifteen-minute wall rather than two nearly-equal ones that
	// drift apart. Changing it therefore moves both.
	TakeControlTTL = 15 * time.Minute
	// MaxControlSessionTTL mirrors the daemon's own cap on a control-session lifetime
	// (internal/protocol/server.go:156, unexported). It is written as a literal for the
	// reason the op names above are: PB-BIND-0 keeps internal/protocol out of phonecore's
	// bound closure, and the S11 tests pin the value against the daemon's.
	MaxControlSessionTTL = 30 * time.Minute
)
View Source
const (
	// StreamJournal is repaired by an atomic roster+events reseed (schema.JournalReseed).
	StreamJournal = "journal"
	// StreamTerminal is repaired by a fresh full server-rendered snapshot. A journal
	// reseed cannot repair a missed grid.
	StreamTerminal = "terminal"
	// StreamReply is repaired by the durable outcome of each op, or it stays unresolved.
	StreamReply = "reply"
	// StreamGrant is repaired by PB-KEY-3's re-grant.
	StreamGrant = "grant"
)

The four REPAIR CHANNELS of PB-SYNC-1. Staleness is MARKED per SEQ BUCKET -- journal and terminal share one (sender, epoch) space and crypto.MailboxResult carries a bare Gap bool with no frame kind, so a skipped seq CANNOT be attributed to one of them -- but it is CLEARED per channel, because the four are repaired by four different things.

View Source
const FreshnessBudget = 5 * time.Minute

FreshnessBudget is section 6.0's "cached-state freshness before it is shown as stale", measured against the newest authenticated machine timestamp the phone has accepted.

It is TIGHTER than InboundMaxAge (10 min) on purpose, and the two are different mechanisms: InboundMaxAge REFUSES a frame outright as a replay backstop, while this one accepts everything it is given and bounds what may be PRESENTED from it. A budget at or above InboundMaxAge would be unreachable -- every accepted frame is inside that window by construction -- which is another way of saying it would never fire.

View Source
const InboundMaxAge = 10 * time.Minute

InboundMaxAge bounds the authenticated age of a MACHINE -> phone sealed frame (PB-TIME-2, value from §6.0). It is the mirror of the gateway's own inbound bound and MUST hold the same number as remotegw.InboundMaxAge -- one §6.0 budget, not two -- which the S11 tests pin across the two packages (phonecore cannot import remotegw: PB-BIND-0).

Like the gateway's, it is a BACKSTOP behind the per-(sender, epoch) replay high-water, not a replacement: a fresh receiver has seen == false for every stream and skips the staleness check on the first frame, so a relay that retained frames would otherwise have them re-accepted at the guard after a restart. Ten minutes sits well above §6.0's +/-30 s skew budget and any plausible relay delay, and well below the 7 d retention cap.

View Source
const ItemSchemaVersion = 1

ItemSchemaVersion is the item schema version this build understands -- the item's own `v` (§2), distinct from journal.SchemaVersion, from protocol.Version and from StateSchemaVersion. A higher one is DEGRADED, never dropped (IS-COMPAT-4).

View Source
const KindStructuredGap = "structured_gap"

KindStructuredGap is the NINTH kind, and it is deliberately not one of §3's eight: no producer of an interaction item ever emits it. It is the daemon's own structured_gap boundary (ADR-017 T2 rule 2) folded into the transcript AS AN ELEMENT, so the tear has a row of its own between the items either side of it.

IT EXISTS BECAUSE THE TEAR WAS INVISIBLE (Wave R6 review finding B4). applyLocked dropped every record whose type was not `interaction`, so a probe that folded item A at cursor 1, a structured_gap at cursor 2 and item B at cursor 3 rendered A and B ADJACENT: the phone drew a continuous conversation across a boundary the daemon had proved was discontinuous. The gap must be able to be DRAWN, and a client can only draw what the fold retains.

View Source
const MaxBackfillPerSession = 200

MaxBackfillPerSession bounds the OLDER items one session may hold from explicit "load earlier" reads (ADR-014), BESIDE MaxItemsPerSession's live window.

IT IS A SECOND REGION AND NOT A LARGER BOUND, and the reason is the Wave R6 round-2 probe: with one bound, insertLocked puts a page's older records at the FRONT and trimLocked evicts oldest-first, so the page and the trim target the same end. At the bound a 50-record page landed and was evicted inside the same call -- 200 items held before, 200 after, none of the 50 surviving -- while the screen went on offering the control, which is the livelock historyPageStart's own doc says it refused to create on the daemon side.

The live window is therefore trimmed WITHOUT counting backfilled items and never evicts one, and the backfill window is bounded by refusing a page that does not fit (ApplyPage). The total a session can hold is the two together, which is what a handset must be able to afford: history the reader asked for, on top of the recent conversation the retention bound exists to protect.

View Source
const MaxClockSkew = 30 * time.Second

MaxClockSkew is §6.0's budget for the offset between the phone's wall clock and the machine's.

View Source
const MaxItemsPerSession = 200

MaxItemsPerSession bounds the durable transcript per session.

The number is §5's own proposed retention figure ("the most recent 200 items per session", IS-CAP-3) and is PROPOSED AND UNRATIFIED, exactly as §5's preamble says of every number in that section. It is reused here rather than invented because the daemon's detail-fetch retention and the phone's transcript retention answer the same question, and two different numbers would mean a client offering "view full output" for items the daemon has dropped.

It is PER SESSION rather than global so a busy session cannot evict a quiet one's transcript, and it is bounded at all because the alternative is MailboxRouter.rebind's recorded OpOutcomes residual ("never pruned, so every launch re-offers every outcome ever recorded") reproduced on a plane that grows per tool call rather than per operation.

View Source
const RecordTypeCapabilityTransition = "capability_transition"

RecordTypeCapabilityTransition is the ordinary, cursor-ordered journal record that publishes the daemon's complete current capability record after a durable runtime degradation or recovery. It carries no transcript item: SessionCache consumes it and a journal event wakes the phone surface to re-read the session in place.

View Source
const RecordTypeInteraction = "interaction"

RecordTypeInteraction is journal.TypeInteraction as it appears ON THE WIRE (IS-LAYER-1, Level 2 of §1's three discriminator levels). It is spelled out here rather than imported: internal/journal is daemon-side and outside the bound dependency closure (PB-BIND-0), and the phone only ever sees the string.

View Source
const RecordTypeStructuredGap = "structured_gap"

RecordTypeStructuredGap is journal.TypeStructuredGap as it appears ON THE WIRE: the daemon-authored capability-degrade boundary of ADR-017 T2 rule 2 / playbook §6.1. Spelled out here for RecordTypeInteraction's reason (internal/journal is outside PB-BIND-0's bound closure).

View Source
const SeqReserveBlock uint64 = 256

SeqReserveBlock is how many seq values one durable reservation covers (§6.0 binds the value). Persisting every seq would cost an fsync per keystroke -- measured at 13-15 ms for the equivalent gateway write, against a p50 <= 150 ms typing budget -- so a CEILING is persisted once per block and the block is handed out from memory. The cost is that a restart burns the unused tail of the block; PB-STATE-8 says which frame absorbs it.

View Source
const StateFileName = "phone-state.json"

StateFileName is the blob's name inside the phone's state directory.

View Source
const StateSchemaVersion = 17

StateSchemaVersion stamps the on-disk blob. A blob stamped with a HIGHER version was written by a newer build and is refused (ErrFutureSchema) rather than decoded with this build's field set: silently dropping a coordinate it does not know means resetting a send-seq ceiling or a receive high-water to zero, which is exactly the replay hole this file closes. Every shipped version keeps a byte-literal fixture in state_test.go that must go on loading (PB-STATE-5).

v2 adds the coordinates the gomobile facade (S8) is the first consumer to need: MachineRelayAuthPub, PushToken, PushPreference and ReconciledEpoch. The bump is what makes a downgrade fail closed -- a v1 build decoding a v2 blob would drop MachineRelayAuthPub and leave the phone with a valid content key, a valid send-seq and no destination, with nothing failing loudly.

v3 seals wake_key and content_key under their PB-KEY-2 tier KEKs (PB-KEY-9). The field SET is unchanged, which is exactly why the bump is required: a v2 build would read v3's sealed bytes AS the key and encrypt every frame under a wrong one, silently. A v2 blob is refused here for the mirror reason -- its cleartext key read as a sealed blob is the same confusion one direction over.

v4 adds stale_streams, the per-REPAIR-CHANNEL staleness PB-SYNC-1 splits out of the per-BUCKET flags (see State.StaleStreams). The bump is what makes the downgrade fail closed: a v3 build decoding a v4 blob would drop the field and report a channel it KNOWS has a hole in it as live, which is the one thing PB-APP-8 forbids.

v5 moves the STATE PB-STATE-9 assigns a tier to out of the cleartext fields and into three sealed containers -- wake_state, content_kept and content_purgeable (see stateFile). v3 sealed the two epoch KEYS and nothing else, so a locked handset still held the decrypted journal, the server-rendered terminal grids and the command outcomes as plain JSON at rest. The bump is required in both directions: a v4 build decoding a v5 blob would find no send_seq and no receive array and start from an empty replay guard -- the exact reset this file exists to refuse -- so it must fail closed instead, and a v5 build reading a v4 blob must know to take those fields from the cleartext and reseal them on the next Save.

v6 adds PushPreference.Version, the DEVICE-supplied monotonic counter PB-PUSH-10 makes the machine refuse a preference update without. The field sits inside an existing container's object rather than at the top level, which is precisely why the bump is not optional: the top-level tag set is unchanged, so nothing else would notice, and a build one version back would drop the counter and restart it at 1 on the next Save. The machine refuses anything that does not STRICTLY exceed what it holds (remotegw.filePushPrefs.SavePrefs, because the relay may replay a frame from before the user turned pushes off) -- so a counter that restarts means every toggle from that moment on is silently refused, forever, while the settings screen shows the user's new value. A brick with no visible symptom. v7 adds relay_spki_pin, the relay certificate's SubjectPublicKeyInfo hash the phone pins at pairing (ADR-007 B33/B34). It has no other channel: the pairing QR cannot carry it (MaxRelayURLLen = 39 already leaves one byte of slack in the v6-L symbol), so msg2 delivers it once and this file is the only place it survives to the next dial. The bump is what makes a downgrade fail LOUDLY rather than quietly: a v6 build reading a v7 blob would drop the pin, and a handset whose platform trust-root source is TrustRootsPinned would then refuse every dial with no way for the user to tell a lost pin from a hostile relay.

v8 adds last_heard_at, PB-APP-11's freshness coordinate: the newest authenticated machine timestamp this phone has accepted. The bump is required for v4's reason exactly one channel over -- a build one version back drops the field, so the next launch reports a machine that has said nothing for hours as live and renders its restored sessions and grids as current. That is the lie PB-APP-8 forbids, and the whole point of the coordinate is that NOTHING ELSE on the phone can notice the condition: a withholding relay leaves no gap, answers every poll, and is itself the source of the only other liveness signal (ADR-007 B121). v9 adds disowned, the record that the OWNER ended this registration (PB-KEY-7's revoke, ADR-007 B133). The bump is required for the same reason v8's was, and the failure it prevents is the worse one: a build one version back drops the field, so the first launch on it comes up believing the phone is paired -- in the four-tab scaffold, holding no key of either tier, reading a roster from a machine that deregistered it, with the pairing entry point on a screen the presentation gate will not show. That is agents-tracker-d0b8 restored by a downgrade, and it is unrecoverable short of clearing the app's data. The field is omitempty, so an INSTALLED v8 blob loads as not-disowned, which is the correct reading of a phone that was paired and never revoked.

v10 adds items, the phone's TRANSCRIPT (ADR-009's structured chat, interaction.go). It sits INSIDE content_purgeable rather than at the top level -- it is a decrypted machine-sealed cache, the same class as sessions and snapshots and the most revealing of the three -- so the top-level tag set is unchanged and TestStateSchemaVersion_IsPinnedToTheDurableFieldSet alone would not have noticed it. The bump is required all the same, and PinnedSealedFixturesStillLoad is what makes it mechanical: a build one version back drops the container's new field, so the transcript comes back EMPTY after the SIGKILL Android hands out routinely -- and because the receive high-water is durable, the relay's redelivery of the frames that built it is refused (crypto.ErrStaleSeq). The loss is permanent rather than re-fetchable, and it takes any pending approval card the machine is still blocked on with it (IS-LIFE-3).

It does NOT bump journal.SchemaVersion or the item's own `v`, which IS-COMPAT-3 forbids. Those two are the WIRE's versions; this one stamps a file only this build writes and only this build reads.

v11 adds Item.LastCursor, the per-item fold high water (interaction.go). It sits one level deeper again -- inside the items array inside content_purgeable -- and the bump is required for the reason every one above it was, with the failure landing on the TRANSCRIPT's contents rather than its presence: a build one version back drops the high water, so the first repair after that launch folds records the phone had already folded, concatenating each increment twice (IS-DELTA-1) and re-collapsing the item's fields to older values. What the user then reads is prose, and wrong, with nothing on any surface marked damaged. v12 adds machine_name, the hostname the machine published in the pairing payload (agents-tracker-ksvb.1). ITS BUMP IS MECHANICAL RATHER THAN ARGUED FROM A FAILURE, and saying so is more honest than inventing one: unlike v7's pin, v8's clock and v9's revoke, a build one version back drops this field and the screens fall back to the endpoint id -- which is what they render today, is a fact, and is not a lie about anything. Nothing breaks.

The bump happens anyway because the rule is unconditional and its value is in being unconditional: TestStateSchemaVersion_IsPinnedToTheDurableFieldSet ties the constant to the durable field set in both directions precisely so nobody has to be right, field by field, about which additions are survivable. A rule with an exemption for "harmless" fields is a rule whose next application is an argument, and pairing is the only channel this coordinate has -- so a downgrade that dropped it silently would restore the very "ep- plus a hash" the bead exists to remove, with no on-device way to tell that from a machine that published no name. An INSTALLED v9 blob loads with an empty name, which is the correct reading of a phone that paired before the field existed: it is not named, so the endpoint id renders.

v12 IS A MERGE ARTEFACT, and saying so keeps the lineage readable: two lines minted a v10 independently -- the transcript above (v10/v11, interaction-program branch), and machine_name below (main) -- so the merged blob carries a field set neither line ever wrote alone. The constant is one past the higher of the two, both narratives are kept verbatim because each still states the failure its own bump was for, and the pinned v12 literal in state_test.go is what makes the union mechanical.

v13 adds relay_tls_policy, ADR-016 W1's named relay TLS policy ("webpki" or "pinned_spki"), independent of relay_spki_pin (W1's own mutation control: a pin's presence must never imply pinned_spki and a pin's absence must never imply webpki -- see W4.4/W9 below). The bump is required for v7's own reason one field over: a build one version back drops the policy and reads the retained pin as the whole of verification again (W3's "consulted iff pinned_spki" collapses to "consulted", the exact defect this ADR exists to retire) on a handset that believes it migrated to webpki.

THE COMMIT NEVER CLEARS relay_spki_pin (ADR-016 W4.4): a webpki phone retains the pin it was last paired or migrated with, and simply stops reading it. So this bump adds a field; it does not remove or reinterpret one, and the v12 fixture's pin survives unchanged into v13's.

v14 adds roster_revision, the durable proof that an authoritative roster reseed committed. Row count and relay cursor cannot prove that event: a valid authoritative roster may be empty at cursor zero, indistinguishable from a paired phone still awaiting its first snapshot. A build one version back drops this proof and returns after restart to calling that state synchronized-empty, so the schema and its pinned literal advance together.

v15 adds relay_incarnation, the durable identity of the mailbox log that minted RelayCursor. Dropping it turns the cursor back into an unqualified number and can skip restored items when a replacement log's numeric high-water catches up.

v16 adds the crash-safe discard-recovery generation, completion and opaque token. A build one version back drops the only proof that a destructive mailbox transaction still owes an authoritative fast-forward roster after process death.

v17 adds last_profile, the exact machine-authored RemoteProfileV1 accepted by the most recent successful Reconcile. Cached session capability records already survive Android process death; without their matching profile they all fail closed to the status card after every SIGKILL, until another reconcile frame happens to arrive. The profile is private inside State so no caller can manufacture this authenticated authority through Save or Mutate. The version bump makes downgrade fail closed instead of silently dropping the predicate that decides whether a restored session may expose chat or terminal control.

View Source
const TerminalControlTTL = TakeControlTTL

TerminalControlTTL is the control generation's horizon (ADR-017 T7). It ADOPTS the number already implemented rather than inventing one, so the system has ONE fifteen- minute wall rather than two nearly-equal ones that will drift apart, and it stays half MaxControlSessionTTL so a generation can never outlive the control-session cap on the strength of its horizon alone.

View Source
const WakeMaxAge = 10 * time.Minute

WakeMaxAge is section 6.0's push envelope TTL: a wake whose AUTHENTICATED issued_at is older than this is refused.

It is a bound on how long a captured wake stays useful, not a freshness nicety. The seq window alone cannot express it: a relay that captures wake seq N and holds it while the gateway is quiet can deliver it days later, and seq N is still the highest the phone has seen. issued_at is AAD-covered (crypto/envelope.go), so it is authenticated rather than advisory -- an attacker cannot move it without breaking the tag.

View Source
const WakeV1MaxAge = 5 * time.Minute

WakeV1MaxAge is PG-WAKE-7's bound for the v1 receiver: five minutes, matching the FCM TTL -- an expiry longer than the TTL is a replay window with no delivery behind it. It is a NEW constant beside the legacy type-0x02 path's WakeMaxAge (10m), which P12 keeps until the migration retires it.

View Source
const (
	WakeV1Size = 74
)

The WakeV1 wire shape (spec section 5.1): one pinned size, a type byte distinct from the mailbox (0x01) and legacy wake (0x02) shapes, and the canonical AAD's domain string. These mirror internal/remotegw's producer constants; the cross-check that the two sides agree is r3a_wakev1_test.go opening the real producer's seal.

TWO COPIES, PINNED BY TEST. ADR-015 P8 says "exactly one constant with schema tests that fail when it moves"; the producer/receiver custody split means each side carries its own copy -- remotegw's WakeV1Size/WakeV1Type/wakeV1Domain/WakeV1Expiry over there, these (and WakeV1MaxAge) here -- and the AAD only opens because WakeV1MaxAge equals WakeV1Expiry millisecond for millisecond. The compiler enforces nothing across the two blocks: what holds them together is r3a_wakev1_test.go opening the REAL producer's seal, so moving either side alone fails loudly there. Never "fix" one side alone; the twin comment obligation on remotegw's block is recorded in the round-2 evidence. WakeV1Size is exported for the ONE routing decision the facade owns: the FCM receipt (mobile.HandlePushWake) routes a payload of exactly this size to AcceptWakeV1 and everything else to the legacy receiver. It is the same pinned copy, not a third.

Variables

View Source
var (
	// ErrNoLease refuses input for a session with no CONFIRMED lease generation: never
	// granted, refused, or severed since. The UX is "take control again".
	ErrNoLease = errors.New("phonecore: no confirmed control lease for this session")
	// ErrLeaseExpired refuses input past the lease horizon. It is DISTINCT from ErrNoLease
	// because the UX differs (re-authorize vs. take control again), and because an expired
	// lease that reported nothing would lose the user's keystrokes silently (PB-INPUT-3).
	ErrLeaseExpired = errors.New("phonecore: the control lease has expired")
)
View Source
var (
	// ErrRegistryNotLive refuses OpenMachineRegistry until the registry commit is
	// durable. A pre-commit crash, a refused migration and a never-migrated phone all
	// answer this: the old singleton state is authoritative in every one of those worlds.
	ErrRegistryNotLive = errors.New("phonecore: no live machine registry at this root")

	// ErrStateMigrated refuses Resume over the OLD singleton dir once the registry is
	// live. It is MM6's load-bearing prohibition -- "never produce two live send
	// sequencers for one pairing" -- as a sentinel: a re-issued seq under a retained
	// epoch is stale-dropped by the gateway permanently.
	ErrStateMigrated = errors.New("phonecore: singleton state was migrated into the machine registry; resume the per-machine namespace instead")
)
View Source
var (
	// ErrPushAddressRevoked refuses re-adopting an address a machine-side revoke severed
	// (ADR-015 P6, PG-WAKE-14): the successor of a revoked address is a DIFFERENT address
	// with its own high-water, so re-adoption is the pin-the-window lever handed back to
	// whoever captured old wakes.
	ErrPushAddressRevoked = errors.New("phonecore: push address was revoked; its successor is a different address")

	// ErrWakePeerClockAhead refuses a GENUINE envelope -- it opened, so every field is
	// authenticated -- stamped further than wakeV1MaxFutureSkew in the future. The guard
	// itself does not move (see the constant): without it a forward-skewed producer hands a
	// network capture a validity window as long as the skew plus WakeV1MaxAge.
	//
	// IT IS A SEPARATE SENTINEL FROM ErrWakeExpired BECAUSE THE TWO NAME DIFFERENT WORLDS.
	// remotegw stamps issued_at from the MACHINE clock and replays the SAME sealed bytes on
	// every retry (PG-WAKE-12), so a machine three minutes ahead has 100% of its wakes
	// refused forever -- a permanently dead wake path whose only trace used to be the opaque
	// WakeDrops total, indistinguishable from someone forging wakes. One is a clock an
	// operator can fix on a machine they own; the other is an attack. The counter they land
	// in tells them apart (WakeDropCounts).
	//
	// IT WRAPS ErrWakeExpired RATHER THAN REPLACING IT. Every caller that already routes on
	// "this wake is outside the freshness bound" keeps working unchanged -- the facade's
	// class mapping, the shipped two-sided-bound assertion -- and the new sentinel is a
	// REFINEMENT a caller may ask for, never a case an existing one silently stopped
	// matching. Nothing about the refusal itself moved.
	ErrWakePeerClockAhead = fmt.Errorf(
		"phonecore: WakeV1 envelope is stamped too far in the future; the sending machine's "+
			"clock is ahead of this phone's: %w", ErrWakeExpired)
)
View Source
var (
	// ErrCorruptState refuses an unreadable or unversioned blob. Starting from an empty
	// checkpoint instead would leave the replay guard blind -- a fresh crypto.MailboxReceiver
	// skips the staleness check entirely -- and re-open every frame the relay still retains.
	ErrCorruptState = errors.New("phonecore: corrupt phone-state file")
	// ErrFutureSchema refuses a blob from a newer build (an upgrade then a downgrade, or a
	// restored backup). Never a silent reinterpretation.
	ErrFutureSchema = errors.New("phonecore: phone-state file schema is newer than this build")
)
View Source
var (
	// ErrWakeReplay refuses a wake at or below the persisted replay coordinate.
	ErrWakeReplay = errors.New("phonecore: push wake replays or reorders the persisted wake coordinate")

	// ErrWakeExpired refuses a wake whose authenticated issued_at is older than WakeMaxAge.
	ErrWakeExpired = errors.New("phonecore: push wake is older than the replay window")

	// ErrNoWakeKey refuses a wake at a phone that holds no epoch wake key, and it is a
	// SEPARATE identity from the AEAD failure a forgery produces because the two are
	// indistinguishable from the bytes and have opposite remedies.
	//
	// A phone paired minutes ago and backgrounded before its first grant landed is in
	// PB-APP-10's third state: it is WAITING, the gateway re-appends its sidecar every
	// session, and the state clears itself. Reported as PB-KEY-3's grant loss instead it
	// sends a healthy user to their machine, and BeginPairing fail-fasts while this device is
	// registered (PB-STATE-10), so the advice cannot even be followed. The facade maps this
	// to ErrClassAwaitingKey; without the distinct identity it could only guess.
	ErrNoWakeKey = errors.New("phonecore: the phone holds no epoch wake key yet, so no push wake can be authenticated")
)
View Source
var ErrAttestationRefused = errors.New("phonecore: push gateway refused the app attestation; no installation enrolled")

ErrAttestationRefused is PG-AUTH-13's typed refusal: the device is not recognised as the licensed Play-signed build, registration is refused, and the honest app state is "foreground updates only" -- never a half-enrolled durable identity.

View Source
var ErrClockSkew = errors.New("phonecore: this device's clock is out of sync with the machine")

ErrClockSkew is PB-TIME-1's distinct, user-legible refusal. It deliberately does not read like the authorization failure it exists to replace: the user has to fix a clock, and "not authorized" tells them to do the wrong thing.

View Source
var ErrContentTierLocked = errors.New("phonecore: the content tier is locked; state sealed under it cannot be updated")

ErrContentTierLocked refuses a Save that would CHANGE content-tier state this process cannot read. It is the fail-closed half of the carry-verbatim rule: carrying the previous blob is right when the caller has nothing to add, and silently discards the caller's write when it does, so the two cases must be told apart rather than merged.

PB-STATE-3's send-seq reservation is the path that meets it. reserveSendSeq is the Sequencer's only durable dependency and issues NO seq when it fails, which is exactly the honest outcome here: a process that cannot read the ceiling cannot raise it, and numbering from 1 instead would have the gateway stale-drop every keystroke, take_control, launch and kill for the life of the epoch. No production send path reaches it -- mobile's resolveSend refuses with errNoContentKey before any seq is drawn, because a frame cannot be sealed without the epoch content key, which is itself content tier -- so this is the fence rather than the normal case.

View Source
var ErrGapPending = errors.New("phonecore: send-seq gap outstanding; a command must absorb it before input")

ErrGapPending refuses an INPUT frame while a burned reservation block is unaccounted for. The gateway DROPS a gapped input frame silently (routeInput), so the first post-restart keystroke would vanish with no signal anywhere; commands ignore the Gap bit, so the re-lease command must absorb it first (PB-STATE-8).

View Source
var ErrGrantLost = errors.New("phonecore: no epoch grant was ever delivered and none is recoverable from the relay; the machine must re-grant")

ErrGrantLost is PB-KEY-3's DELIVERY loss, and it is deliberately a DIFFERENT error from the two custody refusals in internal/remote/crypto:

  • crypto.ErrKeyAuthRequired -- the key is here, the user must authenticate. "Unlock."
  • crypto.ErrKeyInvalidated -- this device's custody is gone. "Re-pair."
  • ErrGrantLost -- custody is fine; the MACHINE's grant never arrived, or the relay purged it before the phone woke. "The machine must re-grant."

Collapsing the third into the second sends a user with a perfectly good handset through a re-pair that BeginPairing refuses outright while a device is registered -- PB-STATE-10's brick, exitable only by physical access to the machine. Three remedies, three identities, and only one of them is the user's to perform.

View Source
var ErrMachineNotFound = errors.New("phonecore: no machine registered with that id")

ErrMachineNotFound is Select's refusal for an id the registry does not hold.

View Source
var ErrMultiMachineNotImplemented = errors.New("phonecore: multi-machine registry mutation is not implemented until MM6/R4")

ErrMultiMachineNotImplemented is the stable, errors.Is-checkable refusal every registry MUTATION gives until MM6/R4 lands a real N-entry registry. It covers both directions: a second Add (there is nowhere to put it) and a Remove of the sole entry (what a zero-machine compatibility adapter would mean is equally undefined before then).

View Source
var ErrNoSealer = errors.New("phonecore: no key-custody sealer supplied")

ErrNoSealer refuses a Resume that would write key material at rest with no KEK over it (ADR-007 B18(c)). The alternative -- sealing anyway under a KEK derived from something on the same disk -- satisfies the letter of PB-SEC-1 while the property stays unmet, so unsealed custody must be a NAMED choice at the call site (InsecureCleartextSealer), never something reached by omitting a field.

View Source
var ErrPublicKeyMismatch = errors.New("phonecore: device.key public keys disagree with the sealed material")

ErrPublicKeyMismatch refuses a device.key whose CLEARTEXT public half disagrees with the material actually sealed inside it. It is a distinct diagnosis from a KEK that will not open the container: the seals are intact and only the unauthenticated claim about them changed, which means the app's private data directory was written to.

View Source
var ErrQueueFull = errors.New("phonecore: op queue full")

ErrQueueFull is returned by Enqueue when the bounded queue is at capacity. The queue is left unchanged -- an already-queued command is never silently dropped (R-PHC.4).

View Source
var ErrRelayIncarnationChanged = errors.New("phonecore: relay mailbox incarnation changed")

ErrRelayIncarnationChanged refuses a destructive mailbox result after the durable transport checkpoint has moved to another relay store generation.

View Source
var ErrUnreconciled = errors.New("phonecore: no reconcile record; mutating ops refused until the machine publishes its authorities")

ErrUnreconciled is the fail-closed refusal PB-STATE-4 demands of every MUTATING op until the machine's reconcile record has been obtained: with no authority the phone cannot know whether its persisted send-seq, receive high-waters or grant watermark were rolled back, so authoring a command is unsafe. It is RECOVERABLE, never latched -- the refusal clears the moment the record lands (PB-STATE-10 forbids a permanent brick), and observation (journal, terminal) is never gated on it.

View Source
var ErrWrongDirection = errors.New("phonecore: mailbox frame sealed for the other direction")

ErrWrongDirection refuses an inbound frame the PHONE ITSELF sealed for the machine and the relay reflected back. It is refused BEFORE the receiver is touched: a reflected keystroke carries a seq thousands ahead of the reply stream (one Sequencer feeds commands and input), so letting it advance the high-water silences every machine reply for the epoch -- no lease confirmation, no operation outcome, and no repair frame exists for the reply channel.

Functions

func AcceptGrant

func AcceptGrant(ks crypto.KeyStore, machineSignPub []byte, g *crypto.EpochGrant) (epochID uint32, grantSeq uint64, keys crypto.EpochKeys, err error)

AcceptGrant is the phone side of the enrollment keystone: after a device-side pairing pins the machine's Ed25519 grant-signing public key (pairing.DeviceOutcome.Machine.MachineSignPub), the phone accepts the sealed initial EpochGrant the machine delivered over the relay and obtains the epoch's wake/content keys.

It verifies the grant against the pinned machine grant-signing pub and opens it with the device KeyStore (crypto.OpenEpochGrant), so a relay that cannot forge the machine's signature cannot inject a key. It fails closed on a grant signed by any key other than the pinned one. This first accept does not enforce (epoch, seq) monotonicity; the phone threads subsequent grants through a crypto.GrantReceiver seeded from this result for replay safety (F3).

func CommandTTLFor

func CommandTTLFor(action string) time.Duration

CommandTTLFor is the signed-ExpiresAt horizon for one action class (§6.0). Only take_control gets the exception; everything else -- kill, delete, launch, device_revoke, terminal_watch -- stays at the 1-minute command TTL, which bounds how long a captured envelope is replayable.

func ComposerAvailable

func ComposerAvailable(rec *schema.SessionCapabilities, profile schema.RemoteProfileV1) bool

ComposerAvailable answers T2 rule 3 at the place the phone currently decides it. The Kotlin detail panel derived it from `transcript.structureTorn` -- a fact about the TRANSCRIPT, which is exactly the inference the rule forbids. A torn transcript and a provider that never had structured chat are different states with different explanations, and only the record can tell them apart.

func EncodePushAddress

func EncodePushAddress(addr PushAddress) string

EncodePushAddress renders an address in its wire form: base64url unpadded, 22 characters (spec section 3.6's PushAddress schema).

func NewPairingWakeKey

func NewPairingWakeKey() (crypto.WakeKey, error)

NewPairingWakeKey mints one phone-generated per-pairing wake key (ADR-015 P7): 32 CSPRNG bytes, fresh per pairing, conveyed to the machine inside the authenticated pairing transcript and never given to the gateway.

func OpenControlReply

func OpenControlReply(key crypto.ContentKey, raw []byte) (schema.Control, error)

OpenControlReply opens a daemon reply Control the gateway sealed and returned via the phone's mailbox (the response half of the command round-trip). Fail-closed on a malformed/wrong-key envelope or non-Control plaintext.

func OpenJournalEnvelope

func OpenJournalEnvelope(key crypto.ContentKey, raw []byte) (schema.JournalRecord, uint64, error)

OpenJournalEnvelope parses and decrypts one mailbox envelope under the epoch content key and decodes the journal record it carries, returning the record and the envelope's Seq. It is fail-closed: a malformed envelope, a wrong/mismatched key, or a non-record plaintext all return an error and NO record (R-PHC.5: reject, never log-and-continue, an item that does not authenticate).

func SealApproveEnvelope

func SealApproveEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, approve schema.ApproveReq) ([]byte, error)

SealApproveEnvelope seals the SIGNED approve command together with its ApproveReq body (IS-LIFE-4), mirroring SealLaunchEnvelope: the body rides beside the signed tuple so the gateway can reconstruct the approve Control the daemon validates against. seq must be unique per epoch.

Only content_hash is bound by the signature. The rest of the body needs no binding it does not already have: agent_instance, interaction_id and expires_at are checked against the daemon's OWN stored tuple, so altering one yields CodeStaleApproval rather than a misapplied decision, and the decision id is deliberately unsigned (IS-LIFE-4) -- it rides inside the epoch-sealed frame, unforgeable by the relay and alterable only by the gateway, which is the documented D4/D5 owner-uid residual.

func SealCommandEnvelope

func SealCommandEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth) ([]byte, error)

SealCommandEnvelope seals a signed command as a mailbox envelope under the epoch content key (XChaCha20-Poly1305), so it can travel through the untrusted relay to the machine as ciphertext. The command's device signature is verified by the daemon after the gateway opens the envelope; sealing adds confidentiality, not the command's authenticity (which the signature already carries). seq must be unique per epoch.

func SealComposerSendEnvelope

func SealComposerSendEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, body *schema.ComposerSendReq) ([]byte, error)

SealComposerSendEnvelope seals the SIGNED composer_send command together with its body (Wave R6), mirroring SealSessionLaunchEnvelope: the body rides beside the signed tuple so the gateway can forward it, the command's ContentHash must be schema.ComposerSendContentHash(body) which the daemon recomputes from the forwarded body, and BodyVersion is bound to the one profile version this phone read (schema.CurrentProfileVersion) -- the daemon refuses any other, absent included. seq must be unique per epoch.

func SealInputData

func SealInputData(key crypto.ContentKey, epochID uint32, seq uint64, session string, data []byte) ([]byte, error)

SealInputData seals a keystroke burst as a mailbox INPUT-FRAME envelope under the epoch content key so it travels through the untrusted relay as ciphertext, mirroring SealCommandEnvelope. session is the target namespaced session id, bound inside the sealed frame so the machine routes by it. seq must be unique per epoch (from a Sequencer shared with commands).

func SealInputResize

func SealInputResize(key crypto.ContentKey, epochID uint32, seq uint64, session string, cols, rows int) ([]byte, error)

SealInputResize seals a terminal resize as a mailbox INPUT-FRAME envelope, mirroring SealInputData. session is the target namespaced session id; seq must be unique per epoch.

func SealInteractionDetailEnvelope

func SealInteractionDetailEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, body *schema.InteractionDetailReq) ([]byte, error)

SealInteractionDetailEnvelope seals the UNSIGNED interaction_detail read (Mirror M3.3, IS-CAP-2), SealInteractionHistoryEnvelope's sibling on every count.

func SealInteractionHistoryEnvelope

func SealInteractionHistoryEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, body *schema.InteractionHistoryReq) ([]byte, error)

SealInteractionHistoryEnvelope seals the UNSIGNED interaction_history read (Mirror M3.1, ADR-014) with its paging body. UNSIGNED is journal_resync's decision verbatim (see schema.ActionInteractionHistory): the tuple carries no device signature, sealing under the epoch content key is already proof the asker is the paired device, and the gates that matter are the daemon's own -- the negotiated `journal` capability and the kill switch.

It carries no BodyVersion for the same reason journal_resync does not: body_version binds a durable semantic MUTATION to a profile (ADR-017 T5), and a read mutates nothing. seq must be unique per epoch.

func SealLaunchEnvelope

func SealLaunchEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, launch *schema.LaunchReq) ([]byte, error)

SealLaunchEnvelope seals a signed launch command together with its LaunchReq spec as a mailbox envelope under the epoch content key. The spec rides alongside the signed tuple (schema.RemoteCommand) so the gateway can forward it to the daemon; the command's ContentHash must be crypto/protocol.LaunchContentHash(launch), which the daemon recomputes from the forwarded spec, so a relay or gateway that alters the spec breaks the signature. seq must be unique per epoch.

func SealLaunchPresetsEnvelope

func SealLaunchPresetsEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth) ([]byte, error)

SealLaunchPresetsEnvelope seals the signed launch_presets read (Wave R5): the machine-authored preset list request. Signed (unlike terminal_watch/journal_resync) because the custody lives daemon-side behind the one authorization plane; carries no body of its own, only the R1 body-version binding. seq must be unique per epoch.

func SealPushPrefsEnvelope

func SealPushPrefsEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, prefs schema.PushPrefs) ([]byte, error)

SealPushPrefsEnvelope seals the SIGNED push_prefs command together with its preference body (PB-PUSH-8, PB-APP-7). seq must be unique per epoch.

The body is deliberately NOT bound by ContentHash the way a launch spec is, and schema.RemoteCommand.PushPrefs records why: a launch spec is forwarded through the gateway in cleartext, so the hash is what stops the gateway altering it, whereas a preference body never leaves the gateway -- it arrives sealed under the epoch content key the relay cannot forge, and the gateway is itself the custodian that decides delivery. The SIGNATURE is this verb's gate; the daemon's capability switch cannot refuse it.

func SealResyncEnvelope

func SealResyncEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, from uint64) ([]byte, error)

SealResyncEnvelope seals the journal_resync read command together with the phone's own cache cursor, mirroring SealTakeControlEnvelope. The command is UNSIGNED (PB-SYNC-5's decision: the gateway serves it and holds no device key), and the cursor is not part of any signed tuple -- it does not need to be, since the frame is sealed under the epoch content key the relay cannot forge, and a wrong cursor only changes how many events the reseed carries. seq must be unique per epoch.

func SealRosterRefreshEnvelope

func SealRosterRefreshEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, from uint64, discardedBacklog bool, recoveryToken string) ([]byte, error)

SealRosterRefreshEnvelope seals the inbox's authoritative roster refresh. It reuses the unsigned journal_resync read and adds an opt-in mode bit, so an older gateway can still decode the command while a current gateway avoids aggregating the transcript backlog into the relay's one-frame reseed. discardedBacklog is true only after the phone completed and durably adopted its explicit self-mailbox discard. A healthy refresh leaves it false and keeps from as the reseed cursor; a recovery refresh lets the gateway advance the bounded roster reseed to the daemon's authoritative final cursor.

func SealSessionLaunchEnvelope

func SealSessionLaunchEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, req *schema.SessionLaunchReq) ([]byte, error)

SealSessionLaunchEnvelope seals a signed session_launch command together with its preset-selection body (Wave R5), mirroring SealLaunchEnvelope: the body rides beside the signed tuple so the gateway can forward it, and the command's ContentHash must be schema.SessionLaunchContentHash(req), which the daemon recomputes from the forwarded body -- a relay or gateway that alters the preset id, revision or prompt breaks the signature. BodyVersion is bound to the one profile version this phone read (schema.CurrentProfileVersion): the daemon refuses any other, absent included. seq must be unique per epoch.

func SealTakeControlEnvelope

func SealTakeControlEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, gateToken string, ttlSeconds int) ([]byte, error)

SealTakeControlEnvelope seals a signed take_control command together with its wire gate token and requested TTL as a mailbox envelope under the epoch content key, mirroring SealLaunchEnvelope. The gate token rides alongside the signed tuple (schema.RemoteCommand) so the gateway can reconstruct the take_control Control frame; the token is bound into the signature via ContentHash = SHA256(gateToken), which the daemon recomputes from this forwarded token, so a relay that alters it breaks the signature. TTLSeconds is not signed (server-clamped). seq must be unique per epoch.

func SealTerminalControlBeginEnvelope

func SealTerminalControlBeginEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, body *schema.TerminalControlBeginReq) ([]byte, error)

SealTerminalControlBeginEnvelope seals the SIGNED begin together with its body, mirroring SealComposerSendEnvelope: the body rides beside the signed tuple so the gateway can forward it, the command's ContentHash must be schema.TerminalControlBeginContentHash(body) which the daemon recomputes from the forwarded body, and BodyVersion is bound to the one profile version this phone read.

func SealTerminalInputEnvelope

func SealTerminalInputEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, machine string, body *schema.TerminalInputReq) ([]byte, error)

SealTerminalInputEnvelope seals ONE UNSIGNED raw-input frame (Wave R8, ADR-017 T6).

IT CARRIES NO DEVICE SIGNATURE, and that is the exception T6 keeps to exactly two body types: what authorises the frame is the E2EE seal's own authenticated sender and sequence PLUS the confirmed generation the daemon re-evaluates on every frame, alongside the kill switch, the device registration and the capability record (T6-e). A per-frame signature would be a keystroke-rate signing operation for authority the frame already carries.

func SealTerminalKeepaliveEnvelope

func SealTerminalKeepaliveEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, machine, session, generation string) ([]byte, error)

SealTerminalKeepaliveEnvelope seals the second and last unsigned frame kind: the generation IS the frame, so it carries no body of its own.

func SealTurnInterruptEnvelope

func SealTurnInterruptEnvelope(key crypto.ContentKey, epochID uint32, seq uint64, cmd schema.DeviceCommandAuth, body *schema.TurnInterruptReq) ([]byte, error)

SealTurnInterruptEnvelope seals the SIGNED turn_interrupt command together with its body (Wave R6), mirroring SealComposerSendEnvelope. The body rides beside the signed tuple so the gateway can forward it, the command's ContentHash must be schema.TurnInterruptContentHash(body) which the daemon recomputes from the forwarded body, and BodyVersion is bound to the one profile version this phone read. seq must be unique per epoch.

THE OP WAS BODYLESS UNTIL THE R6 FIX-PACK; see schema.TurnInterruptReq for what that cost.

func SignApprove

SignApprove authors and signs an approve command (IS-LIFE-4), mirroring SignTakeControl but binding the INTERACTION CONTENT rather than a token: ADR-007 D7 spends the signed tuple's one content slot on it, and the daemon derives the same slot by decoding the WIRE body's content_hash -- so a gateway that swaps the hash to redirect the approval breaks the signature rather than reaching the machine as a well-formed approve for another card.

The hash is DECODED, never derived. IS-APR-2 makes the phone echo `content_hash` verbatim, and a value it cannot decode is refused here: SHA256("") is a valid 32-byte digest, so falling back to an empty hash would produce a structurally-perfect command bound to nothing -- refused at the machine as a stale card, which reports a phone-side bug as the user's problem. This is handleTakeControl's empty-gate-token rule for the other content slot.

func SignCommand

SignCommand authors and signs a remote command with the device's command-signing key (R-PHC authoring side of R-POL.9). It builds the canonical crypto.Command tuple, signs it with the KeyStore, and returns the schema.DeviceCommandAuth the phone sends to the gateway -> daemon. The DeviceID is derived canonically from the command-signing public key, matching how the daemon registry pins it (R-DEV.1), so a signature always verifies against exactly the record its id names.

func SignComposerSend

func SignComposerSend(ks crypto.KeyStore, in ComposerSendInput) (schema.DeviceCommandAuth, error)

SignComposerSend authors and signs a composer_send command, mirroring SignApprove: the signed tuple's content slot is schema.ComposerSendContentHash over the SAME (session, session_instance, expected_turn, text) body SealComposerSendEnvelope carries, so a gateway that alters the text or re-points expected_turn breaks the signature rather than reaching the daemon as a well-formed send of something else. Re-deriving the hash at a call site is the same forbidden duplication mobile/commands.go records for the take_control token.

func SignTakeControl

func SignTakeControl(ks crypto.KeyStore, in TakeControlInput) (schema.DeviceCommandAuth, error)

SignTakeControl authors and signs a take_control command (A7 input), mirroring SignCommand but binding the one-shot gate token into the signature the way launch binds its spec: ContentHash = SHA256(GateToken), Action = schema.ActionTakeControl. The daemon (handleTakeControl) recomputes SHA256 from the WIRE gate token, so a relay that swaps the token yields a different content hash and the device signature fails to verify.

func SignTerminalControlBegin

func SignTerminalControlBegin(ks crypto.KeyStore, in TerminalControlBeginInput) (schema.DeviceCommandAuth, error)

SignTerminalControlBegin authors and signs a terminal_control_begin command, mirroring SignComposerSend exactly: the signed tuple's content slot is schema.TerminalControlBeginContentHash over the SAME (session, session_instance, profile) body SealTerminalControlBeginEnvelope carries.

The INSTANCE is inside the hash, and that is the point of the whole op. A generation bound to a session id alone survives the session's replacement, so a signature authored against one PTY would authorise raw bytes into its successor -- with the user still reading the screen they signed against.

func SignTurnInterrupt

func SignTurnInterrupt(ks crypto.KeyStore, in TurnInterruptInput) (schema.DeviceCommandAuth, error)

SignTurnInterrupt authors and signs a turn_interrupt command, mirroring SignComposerSend exactly -- which is the whole point of the Wave R6 fix-pack's finding B7. The two ops answer the SAME race (a tap lands later than it was rendered) and now carry the same precondition under the same binding, so a gateway that re-points expected_turn breaks the signature rather than reaching the daemon as a well-formed Stop of a different turn. Re-deriving the hash at a call site is the forbidden duplication SignComposerSend records.

func TerminalControlAvailable

func TerminalControlAvailable(rec *schema.SessionCapabilities, profile schema.RemoteProfileV1) bool

TerminalControlAvailable is ComposerAvailable's opposite number on the other destination, and it exists for the reason ComposerAvailable's own comment gives one line up: THE RAW RECORD FIELD IS NOT THE PREDICATE.

mobile/app.go read `out.TerminalControl = rec.TerminalControl` verbatim, three lines under that paragraph, and it cost two things at once (round-3 major 4):

  • with NO mutation and a perfectly VALID record, an opencode session on a machine whose profile carries terminal_view_version == 0 -- which is every machine deployed before this wave -- routes to the STATUS CARD while the facade hands Kotlin `terminalControl = true`. A screen offering a keyboard over a session the router refused is the same defect as a composer over a refused record, one destination over;
  • against an inconsistent record the only guard left was the phone's decode seam, so a single seam was carrying a ruling that amendment T2-b deliberately states at three.

It is written as ROUTE FIRST, then the record's own nil-safe accessor, so control can never outrun the destination: a session that is not on the fallback cannot be typed into, whatever its record says, and a fallback session degraded into that destination may watch and may not control (T6-b).

Types

type Acker

type Acker interface {
	Ack(cursor uint64) error
}

Acker releases one relay mailbox item so the relay may compact it. It is INJECTED because the relay client is outside the bound dependency closure (PB-BIND-0): phonecore must not import it.

type ApprovalBinding

type ApprovalBinding struct {
	ShimPID       int
	ShimStartTime int64
	ContentHash   string
	ExpiresAt     time.Time
}

ApprovalBinding is the ADR-007 D7 binding tuple of one pending approval_request: §3.5's three DAEMON-AUTHORITATIVE fields, read straight off the item the phone stored.

It exists because an approve must ECHO those three and compute none of them (IS-APR-2), and the facade that authors one takes flat strings across the gomobile boundary -- so the tuple has to be recovered from the card the handset is holding, which is this store.

type ApproveInput

type ApproveInput struct {
	Machine     string    // target machine endpoint id
	Session     string    // namespaced session id the approval belongs to
	OperationID string    // durable client-generated idempotency key; NEVER the interaction id (IS-APR-1)
	ExpiresAt   time.Time // command validity horizon (the COMMAND's, not the approval's)
	ContentHash string    // the item's content_hash, echoed verbatim (IS-APR-2)
}

ApproveInput is the identity of an approve op the phone authors (IS-LIFE-4). ContentHash is the approval_request's OWN `content_hash`, as text, exactly as the card carried it.

type AttestFunc

type AttestFunc func(requestHash [32]byte) (string, error)

AttestFunc produces the Play Integrity verdict token for one registration attempt (PG-AUTH-11): requestHash is SHA-256 of the JCS canonicalization of the registration body with attestation.token replaced by the empty string, and the returned token must be bound to exactly that hash or the gateway's own recomputation refuses it.

type Bucket

type Bucket struct {
	Sender [8]byte
	Epoch  uint32
}

Bucket identifies ONE machine -> phone receive stream by exactly the coordinate crypto.MailboxReceiver keys its own high-water map by: the envelope's sender key id and epoch id.

There is more than one bucket per epoch, which is why this is not a scalar: the machine stamps its routing key id on journal, terminal AND reconcile frames (one bucket), while command replies leave SenderKeyID zero (a SECOND bucket with an independent seq space). A scalar high-water would let the reply stream's seq 4 stale-drop the journal stream's seq 4, silently deleting one of the two channels.

type CachedSession

type CachedSession struct {
	SessionID string
	Group     status.Group
	// Agent is the session's agent identity as the machine reported it. The phone never
	// derives it: a session whose records carry no agent has none, and the empty string
	// is that absence rather than an agent.
	Agent string
	// Name is the session's user-given label as the machine reported it. Like Agent it is
	// never derived here: a session whose records carry no name has none, and the empty
	// string is that absence rather than a label. mobile/app.go's session() is where the
	// fallback to the id lives, because that is a DISPLAY decision and this is the model.
	Name    string
	Present bool
	// Capabilities is the machine's own capability record for this session (ADR-017 T2),
	// applied VERBATIM from the roster like Group, Agent and Name -- the phone derives
	// none of it. A nil record is T2-a's honest status card and is deliberately
	// distinguishable from a record that says terminal_fallback=false.
	Capabilities *schema.SessionCapabilities
	// StateSince is the machine's own stamp of when the session entered its current state,
	// applied VERBATIM from the wire like Group, Agent and Name (phone-refit-playbook W7.1): a
	// record carrying one sets it, a record carrying none leaves it alone, and the phone never
	// substitutes its own clock. Zero means no record has carried one yet. The tag is kept
	// where its neighbours have none because omitzero is what keeps an unstamped session's
	// persisted bytes identical to a build that predates the field.
	StateSince time.Time `json:"state_since,omitzero"`
}

CachedSession is the phone's view of one session. Group, Agent and Name are verbatim from the wire.

type CommandInput

type CommandInput struct {
	Action      string    // a schema.Action* constant
	Machine     string    // target machine endpoint id
	Session     string    // namespaced session id (schema.LaunchSessionSentinel for launch)
	OperationID string    // durable client-generated idempotency key (R-PHC.4)
	ExpiresAt   time.Time // command validity horizon
	ContentHash []byte    // optional 32-byte content binding (e.g. protocol.LaunchContentHash)
}

CommandInput is the identity of a remote mutating op the phone authors.

type ComposerSendInput

type ComposerSendInput struct {
	Machine         string    // target machine endpoint id
	Session         string    // namespaced session id the send targets
	SessionInstance string    // exact incarnation rendered by the phone
	OperationID     string    // durable client-generated idempotency key
	ExpiresAt       time.Time // command validity horizon
	ExpectedTurn    string    // the turn the phone rendered the send against ("" = idle)
	Text            string    // the message
}

ComposerSendInput is the identity of a composer_send op the phone authors (Wave R6, Mirror M2.4, IS-LIFE-5). SessionInstance, ExpectedTurn and Text are the body's own fields beside the session, all bound into the signature via ComposerSendContentHash.

type Config

type Config struct {
	Dir           string
	Machine       string
	State         Store
	Ack           Acker
	WakeSealer    Sealer
	ContentSealer Sealer
}

Config assembles a Core. Dir is the phone's state directory (device keys + state blob); Machine is the endpoint id the durable coordinates must belong to (empty adopts whatever the blob describes); State overrides the file-backed custody (test wiring); Ack releases consumed relay items.

WakeSealer and ContentSealer are PB-KEY-2's two tier KEKs, held outside the Go core (PB-KEY-9). Both are REQUIRED whenever anything is written at rest -- Resume fails with ErrNoSealer rather than writing key material in the clear (ADR-007 B18(c)). They are separate because one KEK over both tiers is the collapse the plaintext files already had: the wake KEK is the one the push path opens, so anything under it is reachable by the very process PB-KEY-2 keeps away from session content -- and FCM reads every push payload it carries (ADR-007 A15 as amended by B133), which is why the split is a WIRE property.

type Core

type Core struct {
	// contains filtered or unexported fields
}

Core is the assembled phone: durable custody plus the components resumed from it.

func Resume

func Resume(cfg Config) (*Core, error)

Resume opens the durable state and rebuilds every resume-critical component from it. It fails closed: an unreadable blob or unreadable key custody is an error, never a silent start from zero.

func (*Core) AcceptWake

func (c *Core) AcceptWake(raw []byte) error

AcceptWake authenticates one push-wake envelope and, only if it is genuine, fresh and beyond the persisted coordinate, advances that coordinate DURABLY.

The order is the whole contract and is not an implementation detail:

  1. parse and require type 0x02 -- a mailbox frame must never be accepted here;
  2. require the wake to name the epoch this phone holds a key for;
  3. OPEN under the epoch wake key, which authenticates the header including issued_at;
  4. only then compare seq against State.WakeReplay and issued_at against WakeMaxAge;
  5. only then persist the advanced coordinate.

Steps 3-before-4 is the part that is easy to get backwards and expensive to get wrong. A receiver that advanced the coordinate before authenticating would hand the relay a one-packet permanent denial of service: an unopenable envelope carrying seq 2^63 pins the window at the top and every genuine wake afterwards is refused as a replay, on a device whose ONLY background path from the machine is this one.

Steps 1 and 3 are ONE call for the TYPE. crypto.OpenWake refuses a type-0x01 envelope before it touches the AEAD (A15/F10), so reaching for the header type here would be a second implementation of a rule the frozen package already owns -- and the failure mode of the second copy is a mailbox frame advancing the wake window, which lets the machine's ordinary journal traffic starve the wake path.

STEP 2 IS NOT REDUNDANT WITH THE TAG, which is the reading that leaves it out. The epoch id is AAD-covered, so it cannot be EDITED after sealing -- but a sealer that stamps another epoch from the start produces a tag that verifies perfectly under the key it sealed with. The wake key is per epoch and a revoke rotates it, so an envelope naming another epoch is one this phone must not act on however well it opens; and because the coordinate is NOT reset by a rotation, an accepted old-epoch wake carrying a high seq is the same pin-the-window lever a forgery would be. It is checked against durable state rather than inferred from a decrypt failure, because inferring it is exactly the step that does not hold.

It must work with the content tier LOCKED, because that is the only condition it ever runs in: a push arrives with no user present. Nothing here touches content-tier state -- the write carries the state this process already holds with one wake-tier field changed, so a locked process writes the wake container and carries the two content containers verbatim (see fileStore.Save). There is no rebind either: no derived component reads WakeReplay, and rebuilding the router and the op queue on a wake would be work done with no user present for a coordinate nothing is bound to.

func (*Core) AcceptWakeV1

func (c *Core) AcceptWakeV1(raw []byte) error

AcceptWakeV1 authenticates one 74-byte WakeV1 envelope and, only if it is genuine, fresh and beyond the persisted per-address coordinate, advances that coordinate DURABLY. PG-WAKE-13's order, step by step:

  1. parse; require version 0x01 and type 0x03 before touching the AEAD;
  2. select the per-pairing wake key by push_address; none held is the WAITING verdict (ErrNoWakeKey), never "invalid request";
  3. OPEN under that key, which authenticates every field;
  4. only then compare wake_seq against the per-address high-water and issued_at against WakeV1MaxAge;
  5. only then atomically persist the advanced high-water -- before anything routes.

Step 3 before step 4 is the whole contract: an unopenable envelope carrying seq 2^63 must refuse WITHOUT advancing the coordinate, or any party on the path owns a one-packet permanent denial of service against the phone's only background wake path. Every refusal, on every step, is counted (WakeDrops).

func (*Core) AdoptPushBinding

func (c *Core) AdoptPushBinding(addr PushAddress, key crypto.WakeKey) error

AdoptPushBinding installs one pairing's (address, wake key) as the pairing commit will: the coordinate starts at zero for a fresh address (a wake refused before adoption leaves no residue, PG-WAKE-13 step 2), and a machine-revoked address is refused forever (ErrPushAddressRevoked).

func (*Core) AdoptRelayDiscard added in v0.13.11

func (c *Core) AdoptRelayDiscard(through uint64, incarnation string) error

AdoptRelayDiscard durably advances only the relay-owned read coordinate after the user has explicitly compacted this exact mailbox generation. It does not advance an authenticated receive high-water and does not claim any discarded content was applied; the fresh reconcile and roster reseed requested immediately afterwards repair those application authorities.

func (*Core) BeginRelayDiscardRecovery added in v0.13.11

func (c *Core) BeginRelayDiscardRecovery() (string, error)

BeginRelayDiscardRecovery durably records the recovery generation BEFORE the caller asks the relay to delete anything. Repeated calls while one is pending return the same token, so a restarted phone can safely reissue the relay's idempotent discard and the same sealed roster-refresh intent. No destructive RPC may precede this commit.

func (*Core) DiscardRecoveryToken added in v0.13.11

func (c *Core) DiscardRecoveryToken() string

DiscardRecoveryToken returns the currently pending durable recovery token, or empty when no explicit mailbox discard still awaits its authoritative echoed roster.

func (*Core) DropPushBinding

func (c *Core) DropPushBinding(addr PushAddress) error

DropPushBinding is the local half of the phone-initiated "forget this computer": the KEY is gone, durably, and every wake under it is refused (and counted) from that moment. The HIGH-WATER stays behind as a keyless tombstone, because deleting it is the replay hole the table exists to refuse: a later re-adoption of the same address (a re-pair) starting from zero would accept every wake captured before the forget. The key's HASH stays with it so that re-adoption can tell the same key from a fresh one (see AdoptPushBinding). The gateway half is GatewayClient.RevokeAddress.

func (*Core) EnsurePushRegistration

func (c *Core) EnsurePushRegistration(ctx context.Context, client *GatewayClient, token TokenSource) (PushRegistration, error)

EnsurePushRegistration is the durable orchestration the Kotlin token entry points call (scope 1): register when this installation has never registered, rotate or refresh (PG-AUTH-5 -- the same token re-presented still PUTs, never a dropped no-op) when it has, and fall back to a FRESH registration when the gateway no longer knows the installation -- the alternative is a phone that retries an unauthorized PUT forever and never receives another wake. A registration whose outcome was UNKNOWN (every attempt lost its response) is kept durable and REPLAYED here first (PG-REG-2 across calls), so a maybe-processed POST resolves to its installation instead of minting an orphan.

func (*Core) Grants

func (c *Core) Grants() *crypto.GrantReceiver

Grants is the epoch-grant receiver, seeded at the persisted watermark so a relay cannot replay an old correctly-signed grant after a restart (crypto/epoch.go).

func (*Core) HonorMachineRevoke

func (c *Core) HonorMachineRevoke(addr PushAddress) error

HonorMachineRevoke severs a binding the MACHINE revoked, forever: wakes under the dead key are dropped and counted, the severance survives process death, and the address can never be re-adopted -- its successor is a different address (ADR-015 P6, PG-WAKE-14).

func (*Core) InstallationSigner

func (c *Core) InstallationSigner() (InstallationSigner, error)

InstallationSigner returns this phone's installation signer, MINTING AND PERSISTING the key on first use. The key is durable: an installation id is bound to a public key at the gateway, so a phone that regenerated it would be an installation it can no longer authenticate as -- every rotation refused, and the 180-day inactivity floor running.

func (*Core) KeyStore

func (c *Core) KeyStore() crypto.KeyStore

KeyStore is the device's key custody (never regenerated across a restart).

func (*Core) LastHeard

func (c *Core) LastHeard() time.Time

LastHeard is the newest authenticated machine timestamp the phone has accepted, or the zero time when it has never heard from its machine at all.

func (*Core) LastProfile

func (c *Core) LastProfile() schema.RemoteProfileV1

LastProfile returns the RemoteProfileV1 the most recently SUCCESSFUL Reconcile call adopted (ADR-016 W4.1). It takes no parameters and reads only what Reconcile itself stored, either in this process or in the durable State this process resumed, so there is no way to call it with an unauthenticated or relay-sourced profile -- the migration ladder (App.applyRelayTLSPolicy) has nothing else to pass. Before any reconcile has ever succeeded, or when the machine has published no relay policy at all, this is the zero value, which applyRelayTLSPolicy already treats as "no advertisement": the same no-op an old machine's profile-less reconcile produces.

func (*Core) Leases

func (c *Core) Leases() *LeaseState

Leases is the control-lease gate (PB-INPUT-2), fed automatically from the authenticated inbound path. It starts EMPTY on every Resume: a lease cannot survive a process death, so the only correct durable representation of one is none at all.

func (*Core) MachineSilentAt

func (c *Core) MachineSilentAt(now time.Time) bool

MachineSilentAt is PB-APP-11's verdict at now: the machine's newest word is older than section 6.0's budget, so nothing derived from it may be presented as live.

A phone that has NEVER heard from its machine is silent, not live. That is the fail-closed answer and it is also the true one: a first launch that has restored caches from disk has, by definition, not heard anything this session, and the state clears itself the moment a frame lands.

func (*Core) MarkGrantLost

func (c *Core) MarkGrantLost() error

MarkGrantLost records PB-KEY-3's terminal state durably: the phone has drained its mailbox, found no grant it can open, and the relay can no longer be holding one (the retention cap has passed). Without it the phone has no state at all for this -- every send simply fails with errNoContentKey, which is the indefinite decrypt-failure loop PB-KEY-3 forbids and is indistinguishable on screen from a custody refusal with a completely different remedy.

It is RECOVERABLE, never latched (PB-STATE-10): the machine's re-grant clears it on the ordinary inbound path, in the same transaction that installs the key.

THE PRODUCTION ENTRY is installGrant's replay arm; see grantLossDetected there for what makes the condition decidable without a clock, a threshold or a wire change.

func (*Core) Mutate

func (c *Core) Mutate(fn func(*State)) error

Mutate applies fn to the durable state UNDER THE CORE LOCK, persists the result and rebinds. It is how a caller CHANGES A FIELD, and it is the only shape that is safe to do so from outside the core: the read and the write are one transaction, so nothing can land in between and nothing that landed before it is reverted.

THE HAZARD IT REPLACES was not a lost field, it was a brick. Seven gomobile facade sites read the state, worked with this lock released and Saved the snapshot back. State.EpochID and State.Keys are adopted as given -- fileStore.mergeGuards raises only the replay-guard coordinates -- so a snapshot taken before the drain consumed the machine's epoch grant carried the epoch keys back to what they were, and App.pin ZEROES them itself when the pairing lands in a new epoch, which is right against its own snapshot and destroys the key the grant just installed. resealTier then writes no content-key field at all, so the destruction reaches disk. The window is the normal pairing sequence: pin runs on the pairing goroutine, deliver.go appends the bootstrap frame once per gateway session, and the drain consumes it concurrently.

It is TERMINAL because GrantEpoch/GrantSeq ARE merged monotonically. The watermark survives at the coordinates of the grant whose key was just destroyed, crypto.GrantReceiver enforces strict (epoch, seq) monotonicity, and the gateway re-appending the very same frame next session is refused as a replay -- forever. The phone holds no content key, cannot obtain one, and the only exit is a machine-side re-grant at a higher seq.

fn RUNS WITH c.mu HELD and must touch nothing but the State it is handed. Calling back into the Core from inside it deadlocks.

func (*Core) Ops

func (c *Core) Ops() *OpQueue

Ops is the offline mutating-op queue, restored from State.PendingOps.

func (*Core) PurgeKeys

func (c *Core) PurgeKeys() error

PurgeKeys is PB-KEY-7's revoke/unpair purge at the durable layer: BOTH tier keys and everything sealed under either one are destroyed, in memory and at rest. It REBINDS afterwards for the same reason Save does -- the live objects must come off the purged epoch, or the router keeps opening frames under a key the phone no longer holds.

THE SEALED BLOBS GO. This comment has said both things: ADR-007 B44 struck the claim while the trigger was a screen lock, and ADR-007 B133 moves the trigger to revoke/unpair, where re-pairing rather than a local unwrap is the intended way back. Store.PurgeKeys carries the whole argument.

It is a distinct verb from Save because a Save whose keys are zero is ambiguous: the wake path holds zeros for a content key it could not read and Saves constantly, so custody cannot tell "nothing to write" from "destroy this" by looking at the bytes (see Store.PurgeKeys).

The durable error is REPORTED but never short-circuits the rest: custody purges its own memory unconditionally, so adopting and rebinding is what carries that through to the live objects. Returning early on a failed write left the keys in c.st and bound in the router on a device the owner has revoked -- the half of PB-KEY-7 that cannot fail, gated behind the half that can.

func (*Core) PushFCMToken

func (c *Core) PushFCMToken() string

PushFCMToken is the token the gateway last accepted for this installation, or "" while none was ever registered. It exists so a stale snapshot is detectable at the seam.

func (*Core) PushInstallationID

func (c *Core) PushInstallationID() string

PushInstallationID is the durable installation identity, or "" while this phone has never successfully registered (including after a refused attestation, PG-AUTH-13).

func (*Core) Reconcile

func (c *Core) Reconcile() error

Reconcile adopts the machine's reconcile record (PB-STATE-4/PB-SYNC-7) into EVERY coordinate a rollback could have moved, and persists the result before touching the live objects -- a repair that lived only in memory would be undone by the next process death.

The record is refused unless it names THIS phone's machine and epoch: a relay retaining a pre-rotation record can re-serve it under the retained content key, and its stale InboundHighWater -- adopted monotonically, hence unrewindable -- would push the send-seq into a range the new epoch's gateway stream has never seen.

Until a record is adopted, RequireReconciled refuses mutating ops. The refusal is RECOVERABLE, never latched (PB-STATE-10).

func (*Core) RecordOutcome

func (c *Core) RecordOutcome(ctrl schema.Control) error

RecordOutcome persists the durable outcome of one mutating op (PB-SYNC-2): it is what resolves an op whose reply the phone may never see, so an operation gap does not leave the phone guessing. An UNTAGGED outcome is refused -- attributing it to some op by proximity would persist the wrong verdict for a mutating op.

WHAT IS PERSISTED IS A VERDICT AND NOT A PAYLOAD (Wave R6 review round 2). This stored the WHOLE Control, journal records included, into a map nothing prunes -- this package's own recorded residual, quoted at interaction.go: "never pruned, so every launch re-offers every outcome ever recorded". Wave R6's two reads answer WITH RECORDS, so every history page wrote up to `limit` full item bodies into the phone's durable state file permanently, and every detail read wrote the FULL PRE-TRUNCATION BODY -- precisely the payload that was too large to ship inline. The records belong to the LIVE transcript, which is where the facade folds them the moment the reply is claimed; what has to survive a process death is the answer: which op, what the machine said, and whether it said anything at all.

func (*Core) RewindRelayCursor

func (c *Core) RewindRelayCursor() error

RewindRelayCursor resets the relay read cursor to zero durably, so the next drain re-reads the mailbox from the beginning. It is the ONLY recovery from a relay-poisoned cursor -- see the Store method for why the replay guards make it safe and what bounds the work.

NO REBIND, unlike PurgeKeys and UnsealContent: nothing derived from durable state depends on the read cursor. The drain reads it fresh on every pass (mobile.App.drain), and the receiver, sequencer and router are keyed on the epoch material this does not touch.

func (*Core) Router

func (c *Core) Router() *MailboxRouter

Router is the machine -> phone receive path (demux + replay guard + durable commit).

func (*Core) Save

func (c *Core) Save(st State) error

Save ADOPTS st wholesale as the phone's durable state and REBINDS every component derived from it (send-seq epoch, grant watermark, epoch content key, receive high-waters, caches, op queue). It is the verb for a caller that holds an ENTIRE state and means all of it -- a reseed, a fixture, a whole-blob restore: a Save that persisted the coordinates but left the live objects on the old epoch would keep reserving seqs against a stream that no longer exists.

IT IS THE WRONG VERB FOR CHANGING A FIELD, and Mutate exists for that. The adopt is blind: whatever the caller read is what lands, so every core-internal persist between the caller's read and this write is reverted. See Mutate for the transaction that made that a silent brick on a paired handset.

func (*Core) Seq

func (c *Core) Seq() *Sequencer

Seq is the durable phone -> machine send-seq allocator.

func (*Core) SetRelayIncarnation

func (c *Core) SetRelayIncarnation(incarnation string) error

SetRelayIncarnation durably binds the current relay cursor to the mailbox identity a successful read reported. It is persisted before any item from that read is committed.

func (*Core) SkewMonitor

func (c *Core) SkewMonitor() *SkewMonitor

SkewMonitor is the clock-skew estimator (PB-TIME-1/-3), fed the AAD-covered IssuedAt of every command reply the inbound path authenticates.

func (*Core) State

func (c *Core) State() State

State returns a copy of the durable state as it currently stands.

func (*Core) StreamStale

func (c *Core) StreamStale(stream string) bool

StreamStale reports whether one repair channel's content may not be trusted (PB-SYNC-1), for a caller that holds the Core rather than the router.

func (*Core) TerminalControl

func (c *Core) TerminalControl() *TerminalControlState

TerminalControl is the Wave R8 control-generation gate (ADR-017 T6). It is DELIBERATELY not LeaseState: a generation is not a lease, they have different lifetimes and different ceremonies, and sharing the plane would make a fallback session compete for the shim's single interactive subscriber slot the owner already holds (OPEN-C4).

func (*Core) UnresolvedOps

func (c *Core) UnresolvedOps() []QueuedOp

UnresolvedOps are the queued ops whose durable outcome has never landed. After a burned seq gap (PB-STATE-8) the phone cannot know whether they reached the daemon, so they stay unresolved until an outcome is recorded.

func (*Core) UnsealContent

func (c *Core) UnsealContent() error

UnsealContent restores content operations by re-opening the content tier through its KEK. It is what a process that came up WITHOUT the content tier calls -- the push/wake path holds the wake key and never asks for the other (PB-KEY-2) -- and NOT a way back from PurgeKeys, which destroys the blob it would open.

It REBINDS on success for the same reason PurgeKeys does: the live objects must come back onto the restored epoch key, or the router keeps refusing frames the phone can now open. A refusal changes nothing at all, so a core without the tier stays exactly without it.

func (*Core) WakeDropCounts

func (c *Core) WakeDropCounts() WakeDropCounts

WakeDropCounts is the same counter with the reason attached: it is what separates "the machine I own has its clock three minutes ahead, so every wake it sends is correctly refused forever" (PeerClockAhead) from "somebody is sending this address forgeries" (Unauthenticated) -- two states that were indistinguishable while the only trace was a total. Durable, like the total, so the surface that reads it need not be the process that refused anything.

func (*Core) WakeDrops

func (c *Core) WakeDrops() uint64

WakeDrops is the monotonic count of refused wakes: the scope's "an unverifiable wake is dropped and counted, never acted on", and the trace the degraded-push surface reads.

type Event

type Event struct {
	Kind      string
	Stream    string
	SessionID string
	State     string
	Message   string
	Cursor    int64
	Dropped   int
}

Event is one core-sourced notification. It deliberately mirrors mobile.Event's field set (mobile/types.go) field-for-field, so MachineManager's aggregate stream can relay it unchanged and add only the MachineID qualifier -- see MachineEvent.

type GatewayClient

type GatewayClient struct {
	// contains filtered or unexported fields
}

GatewayClient is the phone's client for one push gateway. Safe for concurrent use.

func NewGatewayClient

func NewGatewayClient(baseURL string, signer InstallationSigner, attest AttestFunc, hc *http.Client) *GatewayClient

NewGatewayClient builds a client over baseURL (scheme://host, no path). hc nil falls back to http.DefaultClient.

func (*GatewayClient) AllocateAddress

func (g *GatewayClient) AllocateAddress(ctx context.Context, installationID string) (PushAllocation, error)

AllocateAddress allocates one unbound per-machine address and its two distinct capabilities (POST /v1/installations/{id}/addresses, spec section 3.3). The triple is shown once; the caller owns conveying or revoking it.

func (*GatewayClient) InstallationID

func (g *GatewayClient) InstallationID() string

InstallationID returns the id this client registered under, or "" before a successful Register.

func (*GatewayClient) Register

func (g *GatewayClient) Register(ctx context.Context, fcmToken string) (PushRegistration, error)

Register mints one installation (POST /v1/installations, spec section 3.1): attested admission, no address allocated. A response lost on the way back is retried with the SAME Idempotency-Key and the byte-identical body (PG-REG-2), so a flaky handset network cannot mint a second durable installation. Callers with durable state use EnsurePushRegistration, which additionally keeps the prepared pair for replay ACROSS calls; this one-shot form prepares and posts in one motion.

func (*GatewayClient) RevokeAddress

func (g *GatewayClient) RevokeAddress(ctx context.Context, addr PushAddress) error

RevokeAddress deletes one address with the INSTALLATION key (DELETE /v1/addresses/{addr}, spec section 3.4): the phone-initiated "forget this computer" arm, and PG-ALLOC-3's immediate cleanup of a failed pairing's allocation. The machine's arm presents its own machine-revoke capability and never comes through here (PG-AUTH-10).

func (*GatewayClient) RotateToken

func (g *GatewayClient) RotateToken(ctx context.Context, installationID, fcmToken string) error

RotateToken replaces the FCM token (PUT /v1/installations/{id}/token, spec section 3.2). It touches no address, capability, wake key or pairing (PG-ROT-1), and re-presenting the current token is the PG-AUTH-5 inactivity refresh. An UNDISCRIMINATED 401 surfaces as errGatewayUnauthorized so EnsurePushRegistration can fall back to a fresh registration; a 401 request_expired has already been clock-corrected and retried by doSigned and surfaces as errRequestExpired, which that fallback must never act on.

type InputCoalescer

type InputCoalescer struct {
	// contains filtered or unexported fields
}

InputCoalescer buffers per-session keystrokes into frames the caller seals verbatim with SealInputData / SealInputResize. Safe for concurrent use: the Android UI thread types while the drain timer flushes.

func NewInputCoalescer

func NewInputCoalescer(now func() time.Time) *InputCoalescer

NewInputCoalescer returns a coalescer reading now for every window decision. The clock is injected because §6.0's rate is a property of the algorithm, not of the host.

func (*InputCoalescer) Abandon

func (c *InputCoalescer) Abandon(reason string) []Undelivered

Abandon discards every buffered byte and resolves it as an explicit "delivery unknown / not sent" (PB-INPUT-1). It is what a disconnect calls: the bytes are neither replayed -- a keystroke landing minutes later is the hazard ADR-007 D7 makes structural -- nor silently dropped, which would leave the user believing they typed it. The returned entries are also retained for the UI to read.

func (*InputCoalescer) AbandonSession

func (c *InputCoalescer) AbandonSession(session, reason string) []Undelivered

AbandonSession is Abandon for ONE session: the shape a per-session severance trigger needs (ADR-017 T6-f). The bytes are DROPPED and recorded as undelivered, never released -- releasing them on "control ended" is exactly the flush that would turn live-only input into a short offline queue at the one place a queue can form.

func (*InputCoalescer) Buffered

func (c *InputCoalescer) Buffered(session string) int

Buffered is how many bytes are held for a session.

func (*InputCoalescer) ClearUndelivered

func (c *InputCoalescer) ClearUndelivered()

ClearUndelivered acknowledges the ledger: the user has read the notice and dismissed it.

It is a separate verb rather than a draining read because the two callers want opposite things -- a screen that OPENS must see the backlog, and a user who DISMISSES it must be able to say so once, for every screen. It does not disable the ledger: a keystroke lost after a clear is recorded like any other, or the acknowledgement would have become PB-INPUT-1's forbidden silent drop, arrived at through its own remedy.

func (*InputCoalescer) Due

func (c *InputCoalescer) Due() []InputFrame

Due returns the frames whose window has elapsed. It is the caller's cadence hook: the tail of a burst is held until either the next Type or the next Due.

func (*InputCoalescer) Fail

func (c *InputCoalescer) Fail(f InputFrame, reason string)

Fail records a frame the coalescer already handed out whose send failed (transport ErrNotDelivered). It is recorded and NOT re-buffered: re-buffering a failed live frame turns the live-only path into a queue, one frame at a time.

func (*InputCoalescer) Flush

func (c *InputCoalescer) Flush() []InputFrame

Flush empties every buffer regardless of the window. It is PB-INPUT-6's boundary mechanism -- release / take_control_end, app backgrounding and the lease horizon passing are one mechanism, because each ends the ability to type and must leave nothing buffered. The bytes are CONSUMED, never copied, so a flushed line cannot be typed twice.

The biometric-freshness expiry that used to head that list is gone with the gate it measured (ADR-007 B133); the lease horizon named in its place is PB-INPUT-3's surviving wall and ends the ability to type for exactly the same reason.

func (*InputCoalescer) Insert

func (c *InputCoalescer) Insert(session string, data []byte) []InputFrame

Insert is the atomic entry point for a PASTE or an IME COMMIT (PB-INPUT-6). Neither is a keystroke stream, so neither is subject to the 125 ms window -- that window exists to coalesce autorepeat, and holding a single event for it buys nothing and costs 125 ms of visible latency. Buffered keystrokes are flushed FIRST so a paste can never overtake characters the user typed before it, and an oversize unit is split at MaxInputPayload and at no other point.

An IME PREEDIT is never sent, and the absence of an entry point for one is the decision: a preedit is local until the IME commits, at which point it arrives here. Sending preedit text would type-then-correct against a live shell.

func (*InputCoalescer) Resize

func (c *InputCoalescer) Resize(session string, cols, rows int) []InputFrame

Resize flushes the session's buffered keystrokes BEFORE the resize frame (PB-INPUT-6). A resize that overtook buffered input is not a cosmetic reordering: the shell re-wraps the line and the bytes land against a grid the user was not looking at when they typed them.

func (*InputCoalescer) Type

func (c *InputCoalescer) Type(session string, data []byte) []InputFrame

Type accepts a keystroke burst and returns the frames that are due now: the leading edge of a burst, plus any 4 KiB chunks the buffer has accumulated. Bytes not returned are held for the rest of the window.

func (*InputCoalescer) Undelivered

func (c *InputCoalescer) Undelivered() []Undelivered

Undelivered is the ledger of everything the phone accepted from the user and could not deliver. It is READ, not drained: the UX state must survive the call that produced it, and a screen that opens after the failure must still see it. ClearUndelivered is the separate acknowledgement; UndeliveredDropped is what the bound discarded.

func (*InputCoalescer) UndeliveredDropped

func (c *InputCoalescer) UndeliveredDropped() int

UndeliveredDropped is how many older entries UndeliveredLedgerSize discarded.

It is a second reader rather than a second return value from Undelivered so the shipped call sites keep compiling -- and because the two answer different questions: one is what to render, the other is what the rendering is leaving out.

type InputFrame

type InputFrame struct {
	T       string `json:"t"`           // "data" or "resize"
	Session string `json:"s,omitempty"` // target namespaced session id
	Data    []byte `json:"data,omitempty"`
	Cols    int    `json:"cols,omitempty"`
	Rows    int    `json:"rows,omitempty"`
}

InputFrame is the plaintext the phone seals into a mailbox envelope for a keystroke burst ("data") or a terminal resize ("resize"). It shares the phone -> machine mailbox with commands; the discriminating `t` field lets the machine side tell an input frame from a RemoteCommand (which carries no `t`).

Session names the target namespaced session id and is bound INSIDE the sealed envelope, so it is authentic end to end: the untrusted relay can drop or reorder sealed frames but cannot alter their contents. The machine routes the keystroke by THIS field, never by mutable focus state -- an input for a session whose take_control the relay dropped then finds no lease and is dropped, never riding another session's live lease (A7 cross-session misroute).

type InstallationSigner

type InstallationSigner interface {
	PublicKey() []byte
	Sign(canonical []byte) ([]byte, error)
}

InstallationSigner signs installation-control requests with the installation private key (PG-AUTH-1/2). PublicKey returns the SEC1 uncompressed P-256 point (65 bytes); Sign returns the IEEE P1363 fixed-width 64-byte r||s signature with s normalized low, over SHA-256 of the canonical string.

type Item

type Item struct {
	SessionID string
	ItemID    string
	// Cursor is the FIRST record's cursor: the item's position in the transcript, which
	// ordering follows (IS-LAYER-3). Taking the latest record's cursor instead would move a
	// streaming message to the end of the transcript on every increment.
	Cursor uint64
	// LastCursor is the cursor of the LATEST record folded into this item: the per-item high
	// water the fold refuses to go behind. IS-LAYER-3 gives items no private sequence number
	// ("for successive records of one streamed item, cursor order IS delta order"), so a record
	// that does not advance past it is a replay or a reorder and not a delta -- and folding one
	// concatenates an increment twice (IS-DELTA-1) and re-collapses the item's fields to older
	// values. The repair channel PRODUCES those records by design: IS-CAP-4 sizes a reseed's
	// events half to fit one frame, so it re-delivers whatever the cut includes.
	//
	// It is per ITEM and not per stream, which is the one place this cannot copy
	// SessionCache's single cursor: a repair legitimately re-delivers records the phone MISSED
	// at cursors BELOW its highest folded one -- that is what the repair is for -- and a
	// stream-wide high water would reject exactly those.
	//
	// It is DURABLE with the item for the reason Resolved is: a guard rebuilt in memory comes
	// back zero, and the first resync after a process death asks from cursor zero (the journal
	// read cursor is memory-only), so the reseed answering it re-delivers the very records that
	// built the restored transcript.
	LastCursor uint64
	Kind       string
	Status     string
	TurnID     string
	TSUnixMs   int64
	Text       string
	Truncated  bool
	Detail     bool
	// Source is a user_message's honest phone-vs-terminal attribution (Mirror M2.4:
	// `source` is daemon-stamped at injection time, never guessed). Empty where the
	// wire carried none -- an absent fact stays absent.
	Source string
	// OperationID names WHICH of this phone's sends the agent echoed back, stamped by
	// the daemon beside [Source] at the same moment and for the same reason: it is the
	// only party that watched the injection.
	//
	// IT IS WHAT LETS A SENT MESSAGE SETTLE HONESTLY (owner ruling R6). A send is
	// acknowledged when the daemon wrote bytes into a PTY, not when the CLI accepted
	// them, so a bubble that settles on the acknowledgement claims a delivery the wire
	// cannot back. This is the echo, and the echo is the delivery. Matching on TEXT
	// instead would inherit the mis-attribution the daemon has probed and refuses to
	// rely on (skeleton/chat.go: an owner-typed "yes" while a phone send of "yes" was
	// pending). Empty on every item nobody claimed -- an owner's own prompt included.
	OperationID string
	// ToolKind is a tool_run's flat glyph vocabulary (Mirror M2.2, `tool_kind`): the §7
	// action type journalled at the item's top level so a card picks a glyph from one
	// field and parses nothing (IS-TOOL-1). Empty where the wire carried none.
	ToolKind string
	// Degraded marks an item whose `v` is higher than ItemSchemaVersion: rendered as far as
	// this build understands it, never dropped and never fatal (IS-COMPAT-4).
	Degraded bool
	// Resolved is set on an approval_request once its approval_resolved has been folded
	// (IS-LIFE-2). It is what lifts the retention exemption of IS-LIFE-3, and it is DURABLE
	// with the item because the exemption has to survive the process death Android hands out
	// routinely -- a flag rebuilt in memory comes back clear and re-exempts a card that was
	// answered hours ago.
	Resolved bool
	// Revision is plan_update's monotonic per-session counter (§3.7). It is carried so a LATE
	// lower revision can be discarded rather than applied (IS-PLAN-1).
	Revision int
	Body     json.RawMessage
	// Backfilled marks an item this phone holds because the READER asked for it -- a page of
	// ADR-014 history folded through ApplyPage -- rather than because the machine pushed it.
	// It is what puts the item in the second retention region: see MaxBackfillPerSession.
	Backfilled bool
}

Item is ONE folded interaction item: every record that carried its item_id, merged by the rules of §6 (IS-ENV-2 folds by item_id, never by position).

Body is the LATEST record's item object VERBATIM, and Text is the reconstruction. The two are not redundant: for a streamed agent_message the latest record carries only the last increment (IS-DELTA-1), so a client that read `text` out of Body would render the tail of a message as the whole of it. Every other kind's fields are read from Body -- this package deliberately decodes none of them, which is what makes an unknown field free (IS-COMPAT-2) and keeps the per-kind rendering where it belongs, in the client.

type ItemStore

type ItemStore struct {
	// contains filtered or unexported fields
}

ItemStore is the phone's transcript: items folded by item_id, ordered by cursor. Concurrency-safe, mirroring SessionCache and SnapshotCache.

func NewItemStore

func NewItemStore() *ItemStore

NewItemStore returns an empty transcript.

func (*ItemStore) All

func (s *ItemStore) All() []Item

All is the whole transcript in cursor order (a snapshot copy) -- what the durable blob carries.

func (*ItemStore) Apply

func (s *ItemStore) Apply(rec schema.JournalRecord) (applied bool)

Apply folds one journal record into the transcript and reports whether it mutated anything. A record that is not an interaction record, or whose item cannot be rendered, returns false -- which is a SKIP and not an error: the frame that carried it is still consumed and its cursor still advances (IS-ENV-3, IS-COMPAT-1), and the caller must not read false as a reason to stale the stream.

func (*ItemStore) ApplyDetail

func (s *ItemStore) ApplyDetail(rec schema.JournalRecord) bool

ApplyDetail folds ONE interaction_detail reply (Mirror M3.3, IS-CAP-2): the full pre-truncation body of an item this phone ALREADY HOLDS, replacing the clipped reconstruction where the card already stands.

IT IS NOT Apply, AND THE DIFFERENCE IS THE FINDING. The daemon's reply carries no cursor -- the body comes out of the capture-time side store, not the journal -- and the card a user taps is `truncated` and `completed`. Both of applyLocked's guards therefore reject it: the cursor does not strictly advance the item, and IS-ST-1 refuses any record after a terminal status. Probed: the fetch folded NOTHING while the press reported success. Forcing a cursor above the high water does not help while the item is terminal, and with a non-terminal item the agent_message branch CONCATENATES (IS-DELTA-1's increment rule) and produces "HEAD...HEAD AND THE WHOLE REST OF IT" -- a garbled body presented as the whole of it, which is exactly the ambiguity IS-CAP-3 exists to forbid.

So a detail reply is a REPLACEMENT and travels its own path: the item keeps its identity, its position and its backfill region, and its body, text and truncation flag become the machine's. A reply for an item this phone does not hold folds NOTHING and says so -- there is no cursor to insert one at, and inventing a position would drop a body somewhere in a conversation nobody can defend.

func (*ItemStore) ApplyPage

func (s *ItemStore) ApplyPage(recs []schema.JournalRecord) (held bool)

ApplyPage folds ONE page of ADR-014 history -- records the READER asked for, strictly older than the item they named -- into the backfill region, and reports whether the page was HELD.

HELD IS NOT "CHANGED SOMETHING". A page whose records this phone already folded lands nothing and is still held: the caller's question is whether there is room for what the machine sent, because the answer it renders is "this phone can hold no more of this conversation", and saying that over a re-delivered page would take the control away for a reason that is not true.

IT IS ALL OR NOTHING, and that is the whole of the bound. A page that does not fit in what MaxBackfillPerSession leaves is refused ENTIRELY rather than partly: half a page is a hole in the middle of a conversation with nothing marking it, which is the silent bridge ADR-017 forbids one plane over. A caller that gets false has not lost data -- the machine still has it -- but it MUST say so rather than go on offering a control that does nothing, which is the livelock this whole region exists to end.

Headroom is measured on a shadow fold of the whole page, exactly as this store would retain it. Counting raw records falsely refuses a streamed item whose many deltas become one item; counting only new ids misses in-place folds such as plan replacement. The shadow keeps the item bound exact without mutating the live transcript before the all-or-nothing decision.

func (*ItemStore) Len

func (s *ItemStore) Len() int

Len is the number of folded items.

func (*ItemStore) PendingApproval

func (s *ItemStore) PendingApproval(session, itemID string) (ApprovalBinding, bool)

PendingApproval returns the binding tuple of the UNRESOLVED approval_request (session, itemID) names, if this phone holds one that can actually be answered.

It reports false in three cases that are one fact from a caller's side -- there is no card here to answer. The item is absent (a reseed floor cut it, or the id is wrong); it has already reached its approval_resolved, so IS-LIFE-2 has spent its one resolution and every surface has stopped showing it; or its §3.5 fields are missing or malformed, which makes it a card no approve can be authored from. The third is worth failing on rather than papering over: an approve carrying an invented tuple is refused CodeStaleApproval, which tells the user their card is out of date when what actually happened is that it arrived broken.

func (*ItemStore) PendingApprovals

func (s *ItemStore) PendingApprovals() []Item

PendingApprovals is every approval_request that has not reached its approval_resolved, across all sessions, in cursor order. It is the read IS-LIFE-3 exists to keep answerable across a reconnect and a process death.

func (*ItemStore) Resolve

func (s *ItemStore) Resolve(sessionID, itemID string) (Item, bool)

Resolve looks one item up by (session, item_id) -- the M3 deep-link landing (Mirror M3, IS-ENV-2: item identity is the item_id, never a cursor or a position). A notification's cursor is stale the moment a reconciliation re-delivers the transcript at new cursors, so the resolver answers the item at its CURRENT fold -- and answers ok=false honestly for an id the retained window no longer holds or that belongs to another session: a deep-link that silently lands somewhere else is worse than one that says "no longer here".

func (*ItemStore) Session

func (s *ItemStore) Session(sessionID string) []Item

Session is the transcript for one session, in cursor order (a snapshot copy).

type JournalReceiver

type JournalReceiver struct {
	// contains filtered or unexported fields
}

JournalReceiver is the phone's replay/reorder/gap-protected journal receive path (R-PHC.5, R-JRN.6). It wraps a crypto.MailboxReceiver plus the epoch content key: an untrusted relay stores the sealed envelopes and can replay, reorder, or drop them, so every envelope is run through the receiver's per-(sender,epoch) seq guard before its record is decoded.

DIRECTION-BLIND, and it has NO production caller: MailboxRouter superseded it (its kind-less branch decodes byte-identically, snapshot.go), and only MailboxRouter.open checks the direction tag before Accept (direction.go). Accept here would take a frame the phone itself sealed for the machine and let it advance the receiver -- the reflection the tag exists to refuse. It stays for the tests that pin the journal decode; do not wire it into a phone.

func NewJournalReceiver

func NewJournalReceiver(key crypto.ContentKey) *JournalReceiver

NewJournalReceiver returns a receiver bound to the epoch content key.

func (*JournalReceiver) Accept

func (r *JournalReceiver) Accept(raw []byte) (rec schema.JournalRecord, gap bool, err error)

Accept parses one sealed envelope, authenticates + seq-guards it through the mailbox receiver, and decodes the journal record. A replayed/reordered seq returns crypto.ErrStaleSeq and a zero record (the caller must NOT apply it). A valid but SKIPPED seq returns gap=true alongside the decoded record, so the phone journal_read-resyncs instead of trusting contiguity.

func (*JournalReceiver) SeedHighWater

func (r *JournalReceiver) SeedHighWater(sender [8]byte, epoch uint32, seq uint64)

SeedHighWater seeds the resume high-water mark for a (sender, epoch) stream to a journal_read snapshot cursor N, so an envelope at seq <= N is rejected on resume (F4).

type Lease

type Lease struct {
	Session    string
	Generation uint64
	ExpiresAt  time.Time
}

Lease is one confirmed control lease. Generation is the DAEMON's, never the phone's: PB-INPUT-2 gates keystrokes on the generation the machine granted.

type LeaseState

type LeaseState struct {
	// contains filtered or unexported fields
}

LeaseState tracks, per session, whether the machine has confirmed a lease the phone may type on. Safe for concurrent use: the inbound drain feeds it while the UI thread types.

func NewLeaseState

func NewLeaseState() *LeaseState

NewLeaseState returns an empty state: no session holds a lease until the machine says so.

func (*LeaseState) Apply

func (l *LeaseState) Apply(ctrl schema.Control)

Apply folds one machine-sealed command reply into the lease lifecycle. It is fed from the AUTHENTICATED inbound path (MailboxRouter), so a relay cannot forge a grant or a severance.

  • OpLease with a NON-ZERO generation confirms a lease. Generation 0 names a lease the daemon does not hold (LeaseManager.Generation reports 0 for a session holding no conn), so it never opens the gate.
  • OpDetach severs it. The notice carries the DEAD generation, so a supersede's late notice cannot kill the replacement lease the daemon is holding open.
  • OpError answering the phone's own take_control is a refusal: the gate stays shut and the reason is kept, because a refused lease that reported nothing is indistinguishable from a slow one.

WHICH LEASE A FRAME IS ABOUT is decided by the take_control operation id first and the generation only second, because the generation does not survive a daemon restart: the counter is in-memory and per-Server lifetime (protocol/server.go:220-224), so the first grant after a restart is generation 1 again while the phone remembers a far higher one as dead. Keyed on the number alone, the forward order silently discards the recovery grant (a dead keyboard and a Take Control button that does nothing) and the reverse order lets the pre-restart lease's late notice sever the lease the daemon has just granted.

A confirmation that names no request the phone is waiting on is still refused when its generation is at or below one already severed: the relay may reorder, so a confirmation sealed before the severance can arrive after it, and re-opening the gate on it would let a keystroke ride a lease the daemon released. Severance drops the recorded request precisely so a replayed grant falls into that case -- but only when the notice is ATTRIBUTABLE to that request (see severNoticedLocked and confirmable).

func (*LeaseState) Background

func (l *LeaseState) Background(reason string)

Background severs every lease because the app left the foreground (ADR-017 T8-b). It is a DIRECT trigger: no transport event of any kind is required, and none is assumed.

func (*LeaseState) Lease

func (l *LeaseState) Lease(session string) (Lease, bool)

Lease is the session's confirmed lease, if it holds one.

func (*LeaseState) Reason

func (l *LeaseState) Reason(session string) string

Reason is why the session holds no lease, for the screen. PB-INPUT-2 requires the suppression be VISIBLE, and an invisible suppression is a dead keyboard.

func (*LeaseState) Requested

func (l *LeaseState) Requested(session, operationID string, expiresAt time.Time)

Requested records that the phone AUTHORED a take_control, with the horizon it signed. It is explicitly NOT a lease -- Require still refuses until the confirmation lands -- but it is what lets a confirmation that carries no expiry of its own fall back to the phone's own horizon (see Apply).

func (*LeaseState) Require

func (l *LeaseState) Require(session string, now time.Time) error

Require is the gate every keystroke, resize and Ctrl-C passes: nil only while a confirmed lease is live and inside its horizon. Exactly AT the horizon is still live, matching the daemon's own deadline comparison -- a phone that gave up a millisecond early would refuse a keystroke the machine would have accepted.

func (*LeaseState) Sever

func (l *LeaseState) Sever(session, reason string)

Sever ends a session's lease from the phone's own side: a transport loss, or a release. The severed generation is remembered so a confirmation for it cannot resurrect the lease.

NO FRESHNESS LAPSE IS A TRIGGER (ADR-007 B133): there is no phone-side authentication left to go stale. What bounds a lease nobody severs is time alone -- the signed ExpiresAt and the daemon's MaxControlSessionTTL, PB-INPUT-3's two walls.

BACKGROUNDING IS A TRIGGER IN ITS OWN RIGHT (ADR-017 amendment T8-b), and Background below is it. This paragraph used to say it was not -- that a backgrounded app loses its lease because backgrounding DISCONNECTS the phone (ADR-007 B16) and the transport loss is what severs. That answer is BY CONSEQUENCE and still holds; it is no longer the whole answer, because it rests on a connectivity choice a later wave could revisit, and ADR-017 T6 makes "only the active foreground screen may send input" a routing rule that an authority outliving its screen would defeat.

func (*LeaseState) SeverAll

func (l *LeaseState) SeverAll(reason string)

SeverAll ends every session's lease. It is the whole-device boundary: the relay connection dropped (so the gateway can seal no notice), or the app stopped being allowed to type at all.

type MachineClient

type MachineClient interface {
	ID() string
	Start() error
	Stop() error
	Running() bool
	Core() *Core
	Events() <-chan Event
}

MachineClient is one pairing entire (ADR-018 MM2): its key store, relay destination, cursors, read models, connection loop, command sequencer and operation journal -- all of which already live on *Core, which is why Core returns the exact pointer rather than a reimplemented read model.

type MachineDescriptor

type MachineDescriptor struct {
	ID          string
	DisplayName string
}

MachineDescriptor is one registry row (ADR-018 MM3).

type MachineEvent

type MachineEvent struct {
	MachineID string
	Event
}

MachineEvent is one Event qualified with the pairing it came from -- ADR-018 MM3's "every event on the aggregate stream is machine-qualified".

type MachineManager

type MachineManager interface {
	List() []MachineDescriptor
	Select(id string) (MachineClient, error)
	Add(d MachineDescriptor, c MachineClient) error
	Remove(id string) error
	Events() <-chan MachineEvent
	ConnectionCap() int
	Close() error
}

MachineManager is the only plural object (ADR-018 MM3): the durable registry of machine descriptors, lifecycle orchestration of the clients, one aggregate event stream, the foreground connection cap, and add/remove/select.

type MachineRegistry

type MachineRegistry struct {
	// contains filtered or unexported fields
}

MachineRegistry is the durable registry of machine pairings under one root. All methods are safe for concurrent use.

func MigrateSingletonToRegistry

func MigrateSingletonToRegistry(cfg MigrationConfig) (*MachineRegistry, error)

MigrateSingletonToRegistry moves the singleton state under cfg.Root into a machine registry keyed by the AUTHENTICATED machine id the durable blob carries. See the file header for the six steps and the crash contract. It is safe to retry after any pre-commit death: a crashed attempt's namespace residue is overwritten, never double-registered, because only the commit writes the registry.

func NewMachineRegistry

func NewMachineRegistry(root string) (*MachineRegistry, error)

NewMachineRegistry creates a fresh, LIVE, initially-empty registry under root -- the first-run multi-machine state, with no singleton to migrate. It refuses a root that already holds a live registry (overwriting one would orphan every namespace it named) and a root that still holds an UNMIGRATED singleton blob: committing an empty registry there makes Resume refuse with ErrStateMigrated -- keyed off registry-file existence alone -- while the registry names zero machines, bricking a pairing whose blob is intact on disk. The migration is that root's only doorway.

func OpenMachineRegistry

func OpenMachineRegistry(root string) (*MachineRegistry, error)

OpenMachineRegistry opens the COMMITTED registry under root. Until the commit is durable it answers ErrRegistryNotLive -- which is what makes every pre-commit crash leave the old singleton state authoritative.

func (*MachineRegistry) AddMachine

func (r *MachineRegistry) AddMachine(d MachineDescriptor) (string, error)

AddMachine creates the per-machine namespace and the durable descriptor row, and returns the namespace directory. A duplicate id is refused and the FIRST registration stands: two namespaces for one pairing is two live send sequencers (MM6 step 5).

func (*MachineRegistry) Entries

func (r *MachineRegistry) Entries() []MachineDescriptor

Entries returns the registered descriptors in registration order.

func (*MachineRegistry) MachineDir

func (r *MachineRegistry) MachineDir(id string) string

MachineDir is the per-machine namespace directory for id. It is only meaningful for a registered id; the path is stated rather than checked so a caller mid-AddMachine can use it too.

func (*MachineRegistry) RemoveMachine

func (r *MachineRegistry) RemoveMachine(id string) error

RemoveMachine deletes exactly that pairing's descriptor and namespace (MM7's forget arm). Every other namespace is not read, not rewritten and not invalidated.

func (*MachineRegistry) Root

func (r *MachineRegistry) Root() string

Root is the directory the registry was created under.

type MachineRow

type MachineRow struct {
	ID             string
	DisplayName    string
	Connected      bool
	Stale          bool
	LastSyncUnixMs int64
}

MachineRow is one immutable row of the aggregate machines snapshot (playbook :574: "immutable snapshots from MachineManager, not mutable singleton globals"). A parked row is Stale with its durable LastSyncUnixMs; a connected row is never Stale.

type MailboxRouter

type MailboxRouter struct {
	// contains filtered or unexported fields
}

MailboxRouter demuxes one shared relay mailbox into the phone's journal and snapshot caches. It owns a single crypto.MailboxReceiver (the shared per-(sender,epoch) seq guard), the epoch content key, and both caches. An untrusted relay can replay/reorder/ drop the sealed envelopes, so every frame is authenticated + seq-guarded ONCE before its plaintext is demuxed -- journal and snapshot frames share one seq space.

func NewMailboxRouter

func NewMailboxRouter(key crypto.ContentKey) *MailboxRouter

NewMailboxRouter returns a router bound to the epoch content key with empty caches. It has no durable custody, so a reconcile record is adopted by its mere arrival: there is no state to validate it against and no coordinate for it to move.

func (*MailboxRouter) Accept

func (r *MailboxRouter) Accept(raw []byte) (gap bool, err error)

Accept parses one sealed envelope, authenticates + seq-guards it through the shared mailbox receiver EXACTLY ONCE, then demuxes on the "kind" discriminator with an EXPLICIT switch over the frame families that share this one mailbox and seq space (C8 / codex#7): a terminal_snapshot updates the snapshot cache; a command_reply is enqueued on the reply cache (drained by the phone, never mistaken for a journal record); a reconcile record fills the reconcile slot (PB-SYNC-7, clearing the mutating-op refusal); an epoch_grant is stashed for pairing / epoch-rotation (C5) to open; a push frame is reserved (dropped); and ONLY a kind-less plaintext takes the existing journal path into the session cache. An unrecognised kind fails closed rather than being mis-applied. gap=true reports a SKIPPED seq (the phone should resync). The seq gap is authenticated the moment r.recv.Accept returns, BEFORE any kind-specific decode runs; every branch that returns after that point reports the TRUE res.Gap (never a hardcoded false), so a decode failure -- an unrecognised kind, or a malformed frame under a future protocol version -- never silently erases a real gap (round-4 re-audit, codex#3 + sonnet#2). A replayed/reordered seq or an unauthenticated frame (res not yet known) returns false and mutates nothing (fail-closed, R-PHC.5).

func (*MailboxRouter) AcceptAt

func (r *MailboxRouter) AcceptAt(raw []byte, now time.Time) (gap bool, err error)

AcceptAt is Accept with the age clock injected, so PB-TIME-2's bound is testable at its boundary without waiting ten minutes. Production reads through Accept and AcceptCommit, which pass time.Now().

func (*MailboxRouter) AcceptCommit

func (r *MailboxRouter) AcceptCommit(raw []byte, cursor uint64) (Receipt, error)

AcceptCommit is Accept plus the durable transaction PB-STATE-7 requires, in the order the two invariants force -- NO FRAME IS BOTH ACKED AND UNAPPLIED, NO FRAME IS APPLIED TWICE:

  1. authenticate and seq-guard the envelope once;
  2. commit ONE transaction -- this bucket's high-water, the stale flags, the relay read cursor AND the decoded content -- BEFORE the ack. Acking a frame whose guard has not advanced would let a redelivery be applied a second time; acking one whose content is not yet durable lets the relay compact the only copy of a frame a SIGKILL is about to erase, and the same durable guard then refuses the redelivery;
  3. mirror the committed frame into the live caches, then ack the relay so the mailbox can compact.

Every step is fail-closed and reports its own error: a failed commit applies nothing and acks nothing (the relay keeps the only copy), and a frame the durable high-water has already seen is REFUSED with crypto.ErrStaleSeq yet still acked -- otherwise the phone re-reads it forever while the relay mailbox never compacts.

"No frame is both acked and unapplied" is exact for a CONTIGUOUS frame. A frame that leaves a HOLE in its bucket is deliberately acked with its GUARD ONLY committed: folding the content of a stream with holes into the durable model is the "later state trusted before reconciliation" PB-STATE-8 forbids, so that frame is delivered to the live caches, its bucket is marked stale, and its content is never made durable. An op such a frame would have resolved therefore stays UNRESOLVED across a restart -- by design, until the op is re-driven or the machine's reconcile record re-establishes the authorities.

func (*MailboxRouter) AcceptCommitAt

func (r *MailboxRouter) AcceptCommitAt(raw []byte, cursor uint64, now time.Time) (Receipt, error)

AcceptCommitAt is AcceptCommit with the age clock injected, so PB-TIME-2's bound -- and the RECOVERY from a phone clock that was wrong -- are testable without waiting ten minutes or moving the host's clock. Production reads through AcceptCommit, which passes time.Now().

func (*MailboxRouter) InboundAgeRefused

func (r *MailboxRouter) InboundAgeRefused() bool

InboundAgeRefused reports that the phone is DISCARDING the frames the machine sends it, because their authenticated age is past PB-TIME-2's bound as this phone reads its own clock.

It is the health signal for a condition the transport cannot see: the websocket is up, so the connection state machine has nothing to say, and a phone that answers "online" while deleting its inbound plane is reporting the destruction as health (ADR-007 B42). It is LIVE -- raised by a refusal, cleared by the next frame the phone takes -- because it describes the drain that is happening now and must not outlive it.

func (*MailboxRouter) Items

func (r *MailboxRouter) Items() *ItemStore

Items is the transcript folded from the journal's interaction records (IS-LAYER-1).

func (*MailboxRouter) Reconciled

func (r *MailboxRouter) Reconciled() (schema.ReconcileRecord, bool)

Reconciled returns the latest reconcile record the router demuxed off the shared mailbox (found=false until one lands). Its three authorities are what PB-STATE-4's resume steps consume: Sequencer.SeedFrom(InboundHighWater), SeedHighWater for the reply bucket (the shared journal/terminal bucket is reseeded by the frame's own arrival), and crypto.NewGrantReceiverAt(GrantEpoch, GrantSeq).

func (*MailboxRouter) Replies

func (r *MailboxRouter) Replies() *ReplyCache

Replies is the command-reply cache the phone drains after driving a command.

func (*MailboxRouter) RequireReconciled

func (r *MailboxRouter) RequireReconciled() error

RequireReconciled is the fail-closed gate every MUTATING op passes through: nil once a reconcile record has been ADOPTED, ErrUnreconciled until then. Reads are deliberately NOT gated -- a phone that shows nothing is indistinguishable from a dead one.

Arrival is not adoption when the router has durable custody: a record naming another machine or epoch is refused by Core.Reconcile, and a refused authority is not a reconciliation. A bare router has nothing to validate against, so arrival is adoption.

func (*MailboxRouter) SeedHighWater

func (r *MailboxRouter) SeedHighWater(sender [8]byte, epoch uint32, seq uint64)

SeedHighWater seeds the resume high-water mark for a (sender, epoch) stream, matching JournalReceiver.SeedHighWater -- an envelope at seq <= N is rejected on resume (F4).

func (*MailboxRouter) Sessions

func (r *MailboxRouter) Sessions() *SessionCache

Sessions is the journal-derived session cache.

func (*MailboxRouter) Snapshots

func (r *MailboxRouter) Snapshots() *SnapshotCache

Snapshots is the server-rendered snapshot cache.

func (*MailboxRouter) Stale

func (r *MailboxRouter) Stale(b Bucket) bool

Stale reports whether a bucket's content may not be trusted. A flag persisted across the restart is one cause; the other is that this epoch has no authority in hand at all -- while the phone is unreconciled, or while a burned seq gap (PB-STATE-8) leaves the fate of its in-flight ops unknown, EVERY bucket of the current epoch is unverified.

func (*MailboxRouter) StreamStale

func (r *MailboxRouter) StreamStale(stream string) bool

StreamStale reports whether one REPAIR CHANNEL's content may not be trusted (PB-SYNC-1).

MARKING is per BUCKET: a gap in the shared (sender, epoch) space marks BOTH journal and terminal, conservatively, because the Gap bit carries no kind. A gap in the sender-zero command-reply space marks only reply.

CLEARING is per CHANNEL and only ever by that channel's own repair, committed atomically with the matching transport watermark (PB-SYNC-3). A failed repair stays stale.

It is deliberately NOT MailboxRouter.Stale's unadopted-epoch clause. That one answers "is this BUCKET's content verified against an authority", which is false for every bucket of an epoch with no reconcile record; this one answers "does this CHANNEL have a known hole", which a phone that has simply not reconciled yet does not.

func (*MailboxRouter) TakeGrant

func (r *MailboxRouter) TakeGrant() ([]byte, bool)

TakeGrant pops the oldest pending epoch-grant plaintext demuxed off the mailbox (found=false when none). Route+expose only: pairing / epoch-rotation (C5) opens it.

type ManagerOptions

type ManagerOptions struct {
	Cap int
	Now func() time.Time
}

ManagerOptions configures a RegistryManager: the documented foreground connection cap and the injected clock.

type MigrationConfig

type MigrationConfig struct {
	Root          string
	WakeSealer    Sealer
	ContentSealer Sealer
	Kill          func(MigrationKillPoint) error
}

MigrationConfig assembles one MigrateSingletonToRegistry run. Kill is the kill-point seam: called at each named MigrationKillPoint, a non-nil return simulates process death AT that point -- the migration stops there, having performed no write beyond it. Nil in production.

type MigrationKillPoint

type MigrationKillPoint string

MigrationKillPoint names one instant of the migration a test can simulate process death at. The points partition the transaction: everything before the commit leaves the old state authoritative, the commit and after leave the registry fully live.

const (
	// MigKillAfterVerify: custody verified, nothing written.
	MigKillAfterVerify MigrationKillPoint = "after_verify"
	// MigKillAfterNamespace: the per-machine namespace directory exists, empty.
	MigKillAfterNamespace MigrationKillPoint = "after_namespace"
	// MigKillAfterStateCopy: the state files are copied into the namespace.
	MigKillAfterStateCopy MigrationKillPoint = "after_state_copy"
	// MigKillBeforeCommit: everything staged, the registry not yet written.
	MigKillBeforeCommit MigrationKillPoint = "before_commit"
	// MigKillAfterCommit: the registry is durably committed; whatever dies now, the
	// decision stands.
	MigKillAfterCommit MigrationKillPoint = "after_commit"
)

type OpQueue

type OpQueue struct {
	// contains filtered or unexported fields
}

OpQueue is the phone's bounded, durable, in-order offline queue of mutating ops (R-PHC.4 / D.0-A4: interrupt/kill/approve/launch -- input is excluded and rides a live take-control session instead). It is replayed in FIFO order on reconnect.

func NewOpQueue

func NewOpQueue(cap int) *OpQueue

NewOpQueue returns an empty queue bounded to cap entries (cap <= 0 means unbounded).

func (*OpQueue) Drain

func (q *OpQueue) Drain() []QueuedOp

Drain returns every queued op in FIFO order and empties the queue.

func (*OpQueue) Enqueue

func (q *OpQueue) Enqueue(op QueuedOp) error

Enqueue appends op, or returns ErrQueueFull (leaving the queue unchanged) when the bound is reached.

func (*OpQueue) Len

func (q *OpQueue) Len() int

Len is the number of queued ops.

func (*OpQueue) Load

func (q *OpQueue) Load(blob []byte) error

Load replaces the queue contents from a Marshal blob (FIFO order preserved).

func (*OpQueue) Marshal

func (q *OpQueue) Marshal() ([]byte, error)

Marshal serializes the queue for durable persistence (R-PHC.4). The persistence layer (R-PHC.8) writes the bytes at rest; Load restores them on next launch.

func (*OpQueue) Peek

func (q *OpQueue) Peek() []QueuedOp

Peek returns a snapshot of the queued ops in FIFO order without removing them.

type PushAddress

type PushAddress [16]byte

PushAddress is the 16 opaque, gateway-minted bytes that route a WakeV1 (PG-ALLOC-1) -- the phone-side twin of remotegw.PushAddress, convertible to it.

type PushAllocation

type PushAllocation struct {
	Address                 PushAddress
	SubmitCapability        string
	MachineRevokeCapability string
	UnboundExpiresAt        time.Time
}

PushAllocation is one allocate-address response (spec section 3.3): the triple is returned exactly once and never again, so the caller must convey it into the pairing or revoke it (PG-ALLOC-3) -- there is no second read.

type PushPreference

type PushPreference struct {
	Alerts   bool `json:"alerts"`
	Mentions bool `json:"mentions"`
	// Version is the DEVICE-supplied monotonic counter the machine gates the update on
	// (schema.PushPrefs.Version, PB-PUSH-10). It is durable for one reason and it is not
	// bookkeeping: the machine refuses any push_prefs whose Version does not STRICTLY exceed
	// the stored one, because the relay is the declared adversary and may replay a frame from
	// before the user turned pushes off. A counter held only in memory restarts at 1 after the
	// process death Android hands out routinely, and every toggle from then on is refused
	// while the settings screen goes on showing the value the user chose.
	//
	// Version 0 is the never-configured record, so the phone's first real update always wins.
	Version uint64 `json:"version,omitempty"`
}

PushPreference is PB-APP-7's pair of coarse notification toggles, persisted so the settings screen renders the user's choice after a restart rather than a default.

type PushRegistration

type PushRegistration struct {
	InstallationID string
	// RefreshBefore is the 180-day inactivity floor; any successfully authenticated
	// installation request moves it (PG-AUTH-5).
	RefreshBefore time.Time
}

PushRegistration is a successful registration's durable result (spec section 3.1).

type QueuedOp

type QueuedOp struct {
	Op        string                   `json:"op"`
	SessionID string                   `json:"session_id"`
	Cmd       schema.DeviceCommandAuth `json:"cmd"`
	Launch    *schema.LaunchReq        `json:"launch,omitempty"`
}

QueuedOp is one offline mutating op awaiting replay: the wire op, its target session, the pre-SIGNED command (carrying the durable operation_id idempotency key), and the launch spec for a launch. The command is signed ONCE at enqueue time; it is never re-signed or re-keyed on replay, so the daemon's idempotency dedups a redelivery.

type Receipt

type Receipt struct {
	Gap         bool
	Acked       bool
	Disposition ReceiptDisposition
}

Receipt reports what became of one committed frame: whether its seq revealed a GAP, whether the relay was acked for it, and -- when an error is returned -- whether the item must remain ahead of all later cursors.

type ReceiptDisposition added in v0.13.11

type ReceiptDisposition uint8

ReceiptDisposition tells a mailbox drain whether an item that returned an error may be skipped while sweeping the rest of the relay page. The distinction cannot be reconstructed from error types at the transport seam: parse/auth/decode refusals are discardable hostile input, while stale-age/custody refusals and a failed durable commit retain recoverable evidence whose later cursors must not advance past it.

const (
	// ReceiptRetained is deliberately zero: a new error return that does not make an
	// affirmative skip decision must stop the page rather than silently advance past it.
	ReceiptRetained ReceiptDisposition = iota
	ReceiptDiscardable
)

type RegistryManager

type RegistryManager struct {
	// contains filtered or unexported fields
}

RegistryManager implements MachineManager over a live MachineRegistry.

func NewRegistryManager

func NewRegistryManager(reg *MachineRegistry, opts ManagerOptions) (*RegistryManager, error)

NewRegistryManager constructs the manager over reg. Cap must be at least 1 -- a manager that may hold no connection at all cannot serve a foregrounded app -- and Now defaults to time.Now. Callers must Close it, or the closer goroutine never runs.

func (*RegistryManager) Add

Add registers client c for pairing d and starts it if the connection cap allows, parking the least-recently-viewed connection otherwise. Every event c emits is relayed onto the aggregate stream qualified with d.ID (MM3), so two machines serving the SAME session id stay two identities (MM4).

func (*RegistryManager) Close

func (m *RegistryManager) Close() error

Close stops every client and retires the relays. Idempotent. The aggregate channel is closed once every relay has exited, so a ranging consumer terminates.

func (*RegistryManager) ConnectedIDs

func (m *RegistryManager) ConnectedIDs() []string

ConnectedIDs are the pairings holding a live connection right now, sorted by id. Never more than Cap.

func (*RegistryManager) ConnectionCap

func (m *RegistryManager) ConnectionCap() int

ConnectionCap is the documented foreground connection cap.

func (*RegistryManager) Events

func (m *RegistryManager) Events() <-chan MachineEvent

Events is the aggregate event stream (MM3): every event machine-qualified, identity always the tuple (machine_id, session_id) (MM4).

func (*RegistryManager) List

func (m *RegistryManager) List() []MachineDescriptor

List returns the managed descriptors in registration order.

func (*RegistryManager) MarkViewed

func (m *RegistryManager) MarkViewed(id string)

MarkViewed records that the user looked at machine id -- the ONLY input the arbitration policy takes -- and re-arbitrates the connected set.

func (*RegistryManager) RecordSync

func (m *RegistryManager) RecordSync(id string, at time.Time)

RecordSync is the connection loop's last-successful-sync hook: the durable fact a parked row's visible age is computed from.

func (*RegistryManager) Registry

func (m *RegistryManager) Registry() *MachineRegistry

Registry is the durable registry this manager arbitrates over.

func (*RegistryManager) Remove

func (m *RegistryManager) Remove(id string) error

Remove forgets pairing id: the client is stopped, the relay retires, and the durable registry row and namespace go with it (MM7's forget arm -- exactly one namespace).

func (*RegistryManager) Rows

func (m *RegistryManager) Rows() []MachineRow

Rows is the immutable snapshot the aggregate UI reads, sorted by machine id. A parked row is Stale with its durable last-sync instant; a connected row is never Stale. Staleness is a pure function of durable facts, never of call timing: two reads under a frozen clock are identical.

func (*RegistryManager) Select

func (m *RegistryManager) Select(id string) (MachineClient, error)

Select resolves id to its client, or ErrMachineNotFound.

type ReplyCache

type ReplyCache struct {
	// contains filtered or unexported fields
}

ReplyCache is a FIFO of the command replies the router demuxed off the shared mailbox, drained by the phone with Take. A reply must land here, never in the session cache (C8 / codex#7). Concurrency-safe, mirroring SnapshotCache.

func NewReplyCache

func NewReplyCache() *ReplyCache

NewReplyCache returns an empty cache.

func (*ReplyCache) Append

func (c *ReplyCache) Append(ctrl schema.Control)

Append enqueues a demuxed reply.

func (*ReplyCache) Len

func (c *ReplyCache) Len() int

Len is the number of undrained replies.

func (*ReplyCache) Take

func (c *ReplyCache) Take() (schema.Control, bool)

Take pops the oldest cached reply (found=false when empty).

func (*ReplyCache) TakeFor

func (c *ReplyCache) TakeFor(operationID string) (schema.Control, bool)

TakeFor claims the cached reply that answers operationID, in ANY arrival order, and consumes it. It is the attribution PB-SYNC-2 / PB-STATE-1 / PB-INPUT-4 need: a phone with two ops in flight cannot repair, persist an outcome or drive retry off a FIFO it cannot attribute.

An UNTAGGED reply (empty operation_id) matches NOTHING -- not the empty key, not some pending op by proximity. Attributing it would persist the wrong outcome for a mutating op, which is worse than leaving it unattributed; Take still drains it, so nothing is silently lost.

type Sealer

type Sealer interface {
	Seal(plaintext []byte) ([]byte, error)
	Open(sealed []byte) ([]byte, error)
}

Sealer wraps key material under a KEK held OUTSIDE the Go core. It is the single inbound crossing ADR-007 B8 pins: the KEK comes in behind this interface and key material never goes out. There is deliberately no accessor for the KEK -- an outbound crossing is what B8 forbids and B17 declined to widen.

func InsecureCleartextSealer

func InsecureCleartextSealer() Sealer

InsecureCleartextSealer returns a Sealer that DOES NOT SEAL: Seal and Open are the identity function, so every byte handed to it is written to disk in the clear.

It exists so unsealed custody is a NAMED choice visible at the call site rather than something reached by omitting a field (ADR-007 B18(c)). The gomobile facade uses it today because the Android side cannot reach phonecore.Config -- gomobile cannot set a Go struct field and mobile.Config is golden-pinned with no verb for a sealer. S14 adds that verb and replaces this call; PB-KEY-9 is not delivered until it does.

type Sequencer

type Sequencer struct {
	// contains filtered or unexported fields
}

Sequencer hands out the strictly increasing seq numbers (1, 2, 3, ...) that stamp EVERY phone -> machine mailbox envelope. Commands AND input frames draw from ONE Sequencer per epoch because they share a single MailboxReceiver key (SenderKeyID stays zero), so a private per-kind counter would collide.

Bound to a Core it is DURABLE: it resumes at the persisted reservation ceiling for its epoch, so no seq is ever re-issued after a process death -- which the gateway's durable inbound high-water (PB-GW-1) would otherwise refuse as stale forever. Unbound (the zero value) it is the plain in-memory allocator.

func (*Sequencer) GapPending

func (s *Sequencer) GapPending() bool

GapPending reports whether a burned reservation block is still unaccounted for.

func (*Sequencer) Next

func (s *Sequencer) Next() uint64

Next returns the next seq (1 on first call) WITHOUT a durable reservation. It is the in-memory allocator; a durable caller uses NextCommand/NextInput, which can report a failed reservation. Safe for concurrent use.

func (*Sequencer) NextCommand

func (s *Sequencer) NextCommand() (uint64, error)

NextCommand allocates the next seq for a COMMAND frame and absorbs any outstanding gap: the gateway ignores the Gap bit on commands, so the re-lease that follows a restart is the frame that pays for the burned block (PB-STATE-8).

func (*Sequencer) NextInput

func (s *Sequencer) NextInput() (uint64, error)

NextInput allocates the next seq for an INPUT frame. While a gap is outstanding it refuses with ErrGapPending rather than emit a keystroke the gateway would drop silently.

func (*Sequencer) SeedFrom

func (s *Sequencer) SeedFrom(n uint64)

SeedFrom resumes the sequencer above n, so the next seq is n+1. It is PB-STATE-4(a): a rolled-back phone restarts its send-seq low and every command it seals is stale- dropped by the gateway's durable inbound high-water (PB-GW-1) -- a permanent refusal with no local symptom -- until the reconcile record's InboundHighWater resumes it here.

MONOTONIC: a stale or lower authority (or a reserved-but-unused seq block, PB-STATE-3) can never rewind an already-advanced counter, which would re-issue a seq the gateway has already consumed. Safe for concurrent use with Next.

type SessionCache

type SessionCache struct {
	// contains filtered or unexported fields
}

SessionCache is the phone's merged session model (R-PHC.3), keyed by namespaced session id. Group, Agent and Name are applied VERBATIM from each record (roster snapshots and group_transition events carry the Group; the roster carries the Agent and the Name); the phone never derives any of them on-device.

func NewSessionCache

func NewSessionCache() *SessionCache

NewSessionCache returns an empty cache.

func (*SessionCache) AdvanceCursor

func (c *SessionCache) AdvanceCursor(cursor uint64)

AdvanceCursor moves the journal READ POSITION without folding anything into the roster. It exists for the one record type that is consumed off this stream and belongs to another model: an interaction item, which shapes the transcript alone (IS-SS-1, interaction.go). The cursor is what Resync resumes from, so leaving it behind on those records would have the phone ask for a range it already holds -- and IS-CAP-4 cuts an oversized reseed at a floor, which is content the phone asked for and lost.

func (*SessionCache) Apply

func (c *SessionCache) Apply(rec schema.JournalRecord) (applied bool)

Apply folds one journal record into the cache and reports whether it mutated. A record with a SessionID ensures the session exists (present); a non-empty Group or Agent updates it verbatim; a deleted record removes it. The cursor advances to the highest applied record cursor. A record whose Cursor is STRICTLY LESS than the highest applied cursor is a stale replay/reorder (defense in depth behind the JournalReceiver seq guard): it mutates nothing and returns false. An equal cursor still applies -- a roster snapshot shares one read cursor across all its sessions.

func (*SessionCache) Cursor

func (c *SessionCache) Cursor() uint64

Cursor is the highest record cursor applied so far.

func (*SessionCache) Get

func (c *SessionCache) Get(id string) (CachedSession, bool)

Get returns the cached session for id.

func (*SessionCache) List

func (c *SessionCache) List() []CachedSession

List returns every cached session (unordered snapshot copy).

type SessionDestination

type SessionDestination int

SessionDestination is the one phone surface a session gets.

const (
	// DestinationStatusCard is T1's third destination AND the fail-closed default. It is
	// the answer for a session that is neither structured nor fallback, for an absent
	// record, for an inconsistent one, for one that binds no instance, and for a machine
	// whose profile declares no TerminalView or no capability-record version.
	DestinationStatusCard SessionDestination = iota
	// DestinationChat is ADR-009 exactly, unchanged, for a structured_chat session. Such a
	// session never sees a terminal.
	DestinationChat
	// DestinationTerminalFallback is the machine-sanitized, read-only-by-default terminal
	// view, and the ONLY destination ADR-009 decision 1 is re-scoped for.
	DestinationTerminalFallback
)

func RouteSession

RouteSession is the whole routing rule, and every fail-closed arm below names a state a user is protected FROM rather than a case that was tidy to add:

  • a zero terminal_view_version means NO FALLBACK EXISTS on this machine -- which is literally what every machine deployed before Wave R8 sends -- so rendering one would be rendering a view the machine never produces (T5-a);
  • a zero capability_record_version means the record is UNTRUSTED, which composes with T2-a into the same predicate;
  • an absent record is the state of every pre-R8 session, so "unknown therefore chat" shows a composer whose every send is refused, and "unknown therefore terminal" opens the peek on all of them (T2-a);
  • an INCONSISTENT record is rejected rather than resolved, because resolving it means choosing which boolean to believe and either choice is a routing decision taken by the reader rather than by the daemon that authored it (T2-b);
  • a record that binds no session instance can bind neither a watch nor a generation to an incarnation (T8-a).

func (SessionDestination) String

func (d SessionDestination) String() string

String makes a failing routing table legible.

type SingleMachineAdapter

type SingleMachineAdapter struct {
	// contains filtered or unexported fields
}

SingleMachineAdapter is ADR-018 MM4's compatibility adapter: it wraps an EXISTING *Core unchanged as a MachineClient. Start/Stop/Running never touch Core itself -- Core has no Close (see core.go), so nothing destructive happens on either transition -- but Stop does have one real effect: it tells the owning SingleMachineManager's relay to stop forwarding this adapter's events (see stopped, below). Both Start and Stop are idempotent so a caller never has to track whether it already called one.

func NewSingleMachineAdapter

func NewSingleMachineAdapter(id string, core *Core, events <-chan Event) *SingleMachineAdapter

NewSingleMachineAdapter constructs an adapter wrapping core under id, relaying events unchanged from the given channel.

func (*SingleMachineAdapter) Core

func (a *SingleMachineAdapter) Core() *Core

Core returns the EXACT *Core pointer this adapter was constructed with -- never a clone.

func (*SingleMachineAdapter) Events

func (a *SingleMachineAdapter) Events() <-chan Event

Events is the adapter's own event stream, unchanged from construction.

func (*SingleMachineAdapter) ID

func (a *SingleMachineAdapter) ID() string

ID is the pairing's identity.

func (*SingleMachineAdapter) Running

func (a *SingleMachineAdapter) Running() bool

Running reports whether Start has run more recently than Stop.

func (*SingleMachineAdapter) Start

func (a *SingleMachineAdapter) Start() error

Start marks the adapter running. Idempotent, and reverses a prior Stop: the manager's relay resumes forwarding this adapter's events once Start runs again. When undoing a completed Stop it retires that cycle's stop signal, so a relay that later parks on stopSignal() waits on a fresh, unclosed channel.

func (*SingleMachineAdapter) Stop

func (a *SingleMachineAdapter) Stop() error

Stop marks the adapter stopped and, if this call is the one making the transition, closes the current stop signal so a relay already parked mid-send (see relay's inner select) abandons that in-flight event rather than delivering it after Stop has returned -- not just events not yet dequeued, which the outer stopped() check alone would catch. Idempotent: a second Stop is a no-op rather than a double-close panic.

type SingleMachineManager

type SingleMachineManager struct {
	// contains filtered or unexported fields
}

SingleMachineManager is ADR-018 MM4's compatibility manager: it holds exactly the one SingleMachineAdapter as its sole registry entry and relays that adapter's events onto the aggregate stream, qualified with its machine id.

It takes the concrete *SingleMachineAdapter, not a MachineClient, because relay needs adapter's private stopped() half of the lifecycle state (see stopped's doc) that MachineClient deliberately does not expose -- callers of the interface only ever need Running(). That is fine while Add/Remove refuse behind ErrMultiMachineNotImplemented, so no other MachineClient can ever reach this constructor; MM6/R4's real registry is what makes this join generic.

func NewSingleMachineManager

func NewSingleMachineManager(displayName string, adapter *SingleMachineAdapter) *SingleMachineManager

NewSingleMachineManager constructs a manager around adapter, displayed as displayName, and starts relaying adapter's events onto the aggregate stream. Callers must call Close when done with the manager, or the relay goroutine runs forever.

func (*SingleMachineManager) Add

Add always refuses: there is nowhere to put a second pairing before MM6/R4.

func (*SingleMachineManager) Close

func (m *SingleMachineManager) Close() error

Close stops the relay goroutine. Idempotent; safe to call more than once or concurrently. It does not touch the wrapped adapter or Core -- Close is the manager's own shutdown, not the client's (that is Stop).

func (*SingleMachineManager) ConnectionCap

func (m *SingleMachineManager) ConnectionCap() int

ConnectionCap is fixed at 1: the compatibility adapter can never exceed one live connection, so there is nothing to arbitrate.

func (*SingleMachineManager) Events

func (m *SingleMachineManager) Events() <-chan MachineEvent

Events is the aggregate event stream (MM3).

func (*SingleMachineManager) List

List returns the sole compatibility-adapter entry.

func (*SingleMachineManager) Remove

func (m *SingleMachineManager) Remove(string) error

Remove always refuses: removing the sole entry is equally undefined before MM6/R4.

func (*SingleMachineManager) Select

Select resolves id to the wrapped adapter, or ErrMachineNotFound for anything else.

type Skew

type Skew struct {
	Offset time.Duration
	RTT    time.Duration
	Known  bool
}

Skew is one measurement. Offset is MACHINE MINUS PHONE, so a phone that runs AHEAD yields a negative offset; RTT is the round trip the measurement rode, i.e. the width of the uncertainty bracket. Known is false until a round trip has completed.

type SkewMonitor

type SkewMonitor struct {
	// contains filtered or unexported fields
}

SkewMonitor brackets the machine's authenticated timestamps against the phone's own send and receive instants. Safe for concurrent use: commands are minted on one thread and replies land on the drain.

func NewSkewMonitor

func NewSkewMonitor(now func() time.Time) *SkewMonitor

NewSkewMonitor returns a monitor with no measurement yet.

func (*SkewMonitor) Check

func (m *SkewMonitor) Check() error

Check is the last measurement's VERDICT, for reporting. It mirrors the last Observe exactly, so the user is never told one thing while the phone believes another.

IT IS NOT A GATE, and no command-authoring path may make it one. The only authenticated machine time the phone can get rides a reply, and a reply only exists in answer to a command -- so refusing commands on a bad verdict refuses the command that would have re-measured it, and the refusal outlives the broken clock it was reporting: the user corrects their clock exactly as the error said and every op stays refused until the process restarts. The daemon's own ExpiresAt check is the enforcement; this is the explanation. Its one production caller is the phone's reply handler, which reports the verdict on the event plane (mobile/relay.go reportSkew), and mobile/s11r_livesend_test.go fences the command path against calling it at all.

UNKNOWN IS LIKEWISE NEVER A VERDICT: a phone that has never measured reports nothing rather than a fault it cannot substantiate, and a bad verdict is never latched -- the next good measurement clears it.

func (*SkewMonitor) Observe

func (m *SkewMonitor) Observe(operationID string, machineTime time.Time) (Skew, error)

Observe closes the bracket with the machine's AUTHENTICATED timestamp from the reply to operationID, and returns the current measurement plus the verdict.

A stamp the phone cannot tie to a send it made has no T1, so it has no bracket: it is IGNORED, never folded in with an assumed delay. That is the half that keeps a retaining relay from steering the estimate -- hold a frame for ten minutes, deliver it, and an assuming phone concludes its clock is ten minutes fast.

func (*SkewMonitor) Sent

func (m *SkewMonitor) Sent(operationID string)

Sent records T1 for an operation, at the moment the phone hands it to the relay. Without it every machine stamp arrives uncorrelated and is ignored by design, so the phone can never measure skew at all.

func (*SkewMonitor) Skew

func (m *SkewMonitor) Skew() Skew

Skew is the last completed measurement.

type Snapshot

type Snapshot struct {
	Session string
	Lines   []string
	Cols    int
	Rows    int

	// The versioned view's own coordinates (ADR-017 T4-a / T8-a), carried since the closing
	// round because they were produced by the daemon and thrown away before the wire.
	//
	// SessionInstance is WHICH INCARNATION this screen belongs to, ViewEpoch is which
	// render-loop run produced it, Revision is its order within that run, and Reset marks the
	// first emission of every epoch on every path. RenderedAt is the MACHINE's clock, which is
	// the only clock a staleness indicator may be written over: arrival time renders a replayed
	// backlog as fresh and a held relay as live.
	//
	// A machine that predates the closing round sends none of them, and zero is the honest
	// answer there -- "this machine does not version its views" -- never a fabricated epoch.
	SessionInstance string
	ViewEpoch       uint64
	Revision        uint64
	Reset           bool
	RenderedAt      time.Time
}

Snapshot is the phone's cached view of one session's server-rendered terminal grid: sanitized plain-text lines exactly as the daemon rendered them. The phone is THIN -- it holds text only, never a VT emulator (A7 renderer split).

type SnapshotCache

type SnapshotCache struct {
	// contains filtered or unexported fields
}

SnapshotCache holds the latest server-rendered snapshot per session, keyed by namespaced session id. Latest wins: a newer snapshot replaces the prior one (frames arrive in increasing seq behind the mailbox seq gate, so last-applied is newest). Concurrency-safe, mirroring SessionCache.

func NewSnapshotCache

func NewSnapshotCache() *SnapshotCache

NewSnapshotCache returns an empty cache.

func (*SnapshotCache) Apply

func (c *SnapshotCache) Apply(s Snapshot)

Apply stores s as the latest snapshot for its session, under ADR-017 T4-a's ordering rule:

differing epoch = HARD RESET, adopt s and discard what is held
same epoch      = adopt s only if its revision is STRICTLY GREATER

LATEST-WINS WAS WRONG IN ONE DIRECTION AND HIGHEST-REVISION-WINS IS WRONG IN THE OTHER, which is exactly why the epoch exists. Two frames of one run that arrive out of order left the phone on the older screen permanently; and a rule written over the revision alone discards every frame of the NEXT run, because a new render loop restarts the revision at 1 while the phone holds N. That second failure is the frozen screen T4-a names: a session replaced under the same id, rendering a plausible view that stopped being true.

AN UNVERSIONED FRAME ALWAYS LANDS (epoch 0). Two things produce one and both must be applied: a machine that predates the closing round, whose every frame is epoch 0 and would otherwise never update; and `Gateway.BlankTerminal`, the machine saying "I am no longer rendering this" to a phone that never asked it to stop -- a blank dropped as unversioned is a dead grid the screen goes on calling fresh.

AND THE MACHINE'S OWN `reset` MARKER ALWAYS LANDS, whatever the epoch and revision say (closing round 2). The epoch counter is a bare process-global in the daemon (internal/daemon/terminalview.go), so it RESTARTS AT 1 in every daemon process -- and a session surviving a daemon crash, restart or upgrade is a designed property of this system. A phone holding {epoch 1, revision 40} therefore discarded the restarted daemon's {epoch 1, revision 1} AND the 39 revisions after it, and the user read a plausible, frozen, pre-restart terminal: the same failure T4-a names, in the variant where the epoch happens to collide. `reset` is documented (protocol.md, T4-a) as "true on the FIRST snapshot of every epoch, on every path" and as what "tells the phone to discard prior state"; the daemon has always sent it and the decoder has always read it. Reading it HERE is what makes the epoch's process-locality harmless -- a colliding epoch is still a hard reset, because the machine says it is one -- and it is deliberately the only way a lower revision may win, so the reorder rule above is untouched for every frame the machine did not mark.

func (*SnapshotCache) Get

func (c *SnapshotCache) Get(session string) (Snapshot, bool)

Get returns the latest cached snapshot for session.

func (*SnapshotCache) Len

func (c *SnapshotCache) Len() int

Len is the number of sessions with a cached snapshot.

type State

type State struct {
	Machine string // machine endpoint id these coordinates belong to
	// MachineName is the machine's own HOSTNAME as it published it in the pairing payload
	// (pairing.MachinePayload.Hostname, from machineid.Identity.Hostname()). It is DISPLAY
	// ONLY: Machine above stays the identity every signed command carries and the key
	// OpenStore filters this blob on, and nothing internal ever keys on this string.
	//
	// IT IS A SECOND COORDINATE RATHER THAN A BETTER Machine, and that is forced rather than
	// preferred, for the reason Disowned's own paragraph gives: a hostname is not unique, not
	// stable and not something crypto.Command.Canonical could sign over, so writing it into
	// Machine would take the endpoint id's place in the load-time filter and discard the whole
	// blob on the next process start.
	//
	// EMPTY MEANS THE MACHINE PUBLISHED NONE, which is an ordinary state -- a machine identity
	// generated where os.Hostname() failed carries "" -- and the render sites fall back to the
	// endpoint id, which is a fact rather than a fabrication (ADR-007 B135).
	//
	// IT IS CLEARTEXT for RelaySPKIPin's reason: it is a record ABOUT the registration rather
	// than content under it, and a screen that names the machine must be able to do so on a
	// push-woken process with the content tier locked.
	MachineName    string
	MachineStatic  []byte // machine Noise-static public key pinned at pairing
	MachineSignPub []byte // machine Ed25519 grant-signing public key pinned at pairing
	// MachineRelayAuthPub is the machine's relay-auth Ed25519 public key, pinned at
	// pairing. Everything above records who the machine IS; this is the only coordinate
	// that says how to REACH it: relay.RoutingID(MachineRelayAuthPub) is the mailbox every
	// command and keystroke is appended to, and it is what the phone authorizes so the
	// machine may append back. Without it a restored phone holds a valid content key, a
	// valid send-seq and no destination -- and nothing fails loudly.
	MachineRelayAuthPub []byte
	// RelaySPKIPin is the SHA-256 of the relay certificate's SubjectPublicKeyInfo, pinned
	// at pairing from MachinePayload (ADR-007 B33/B34). Empty means the machine published
	// no pin. It is a coordinate rather than a key: it says which relay this phone is
	// willing to reach the machine through, and it must survive a restart because pairing
	// is the ONE authenticated moment it can be learned.
	RelaySPKIPin []byte
	// RelayTLSPolicy is ADR-016 W1's named relay TLS policy ("webpki" or "pinned_spki"),
	// adopted verbatim from the machine's authenticated statement about its own relay --
	// at pairing (B54) or through the W4/W9 migration ladder. Empty means a phone that has
	// never learned one (pre-ADR-016, or paired before this field existed).
	//
	// IT IS INDEPENDENT OF RelaySPKIPin ABOVE, IN BOTH DIRECTIONS (W1's mutation control,
	// W4.4). Setting one never touches the other: a webpki phone RETAINS whatever pin it
	// holds and simply stops reading it (W3's single rule, "a pin is consulted if and only
	// if the effective relay TLS policy is pinned_spki") -- clearing it on migration would
	// leave nothing to fall back to if the machine ever reverts, and would fight B54's
	// verbatim-adoption rule the next time a reconcile republishes an absence.
	RelayTLSPolicy string
	// Disowned records that the OWNER ENDED this registration -- the revoke behind "Replace
	// this computer" (PB-KEY-7's trigger, ADR-007 B133). Every coordinate above says what the
	// pairing pinned; this is the only one that says the pairing is over, and without it "is
	// this phone usably paired" has no answer on a handset whose keys were destroyed
	// (agents-tracker-d0b8).
	//
	// IT IS A COORDINATE OF ITS OWN RATHER THAN AN EMPTIED Machine, and that is forced rather
	// than preferred. Machine is what OpenStore FILTERS the durable blob on, so a store that
	// wrote an empty one would write a blob it discards on the next process start -- the
	// pairing, the epoch, the sealed content key, the relay cursor and the send-seq ceilings,
	// silently, on the first Android process death. That is S9, and OpenStore's initialiser
	// paragraph exists to make it unwritable; clearing the field from a caller puts it back by
	// another door. Machine is also what every mutating verb signs over
	// (crypto.Command.Canonical refuses an empty one), so a cleared name outlives the revoke
	// as a phone that cannot author anything even once it has paired again.
	//
	// NOR IS IT DERIVABLE FROM KEY MATERIAL, which is the other shape it could have taken and
	// the one that needs no new field. Holding no content key is the ORDINARY condition of a
	// push-woken process (PB-KEY-2) and of a paired phone between an epoch rotation and the
	// grant that fills it: hasSealedContentKey answers false for a blob left over from the
	// previous epoch, which is exactly where mobile.App.pin leaves a pairing that lands in a
	// new one. A gate derived from key material shows the pairing screen to a phone that has
	// just finished pairing.
	//
	// IT IS CLEARTEXT for StaleStreams' and LastHeardAt's reason: it is a record ABOUT the
	// registration and not content under it, and the load path has to read it with the content
	// tier locked -- which is where a revoked phone permanently is, the purge having destroyed
	// the tier.
	//
	// IT IS NOT MONOTONIC, unlike the replay guards it sits near. A pairing CLEARS it, because
	// a pairing is the owner acting and is the one act that makes a disowned registration
	// current again -- a flag that could only be set would replace an unpairable phone with a
	// permanently unpairable one. What protects it from a writer that has not noticed the
	// revoke is the purge stamp rather than a merge rule; see Save and disown.
	Disowned         bool
	RoutingID        string            // this phone's relay routing id
	EpochID          uint32            // current epoch the content key belongs to
	Keys             crypto.EpochKeys  // wake + content keys for EpochID
	SendSeq          map[uint32]uint64 // per-epoch DURABLE send-seq reservation ceiling (PB-STATE-3)
	Receive          map[Bucket]uint64 // per-(sender,epoch) receive high-water (replay guard)
	GrantEpoch       uint32            // highest accepted grant epoch (PB-STATE-4(c))
	GrantSeq         uint64            // highest accepted grant seq for GrantEpoch
	WakeReplay       uint64            // highest accepted push-wake counter
	RelayCursor      uint64            // relay mailbox read cursor the next poll resumes from
	RelayIncarnation string            // durable identity of the relay mailbox RelayCursor belongs to
	// DiscardRecoveryGeneration, DiscardRecoveryCompleted and DiscardRecoveryToken are the
	// crash-safe transaction spanning an explicit self-mailbox discard and its replacement
	// roster. BeginRelayDiscardRecovery advances Generation and persists a fresh Token BEFORE
	// the destructive relay call. The exact echoed token advances Completed and clears Token
	// in the same Save that accepts the authoritative fast-forward reseed. A pending recovery
	// is Generation > Completed; all generations are monotonic so stale writers cannot erase
	// or resurrect the intent around an Android process death.
	DiscardRecoveryGeneration uint64
	DiscardRecoveryCompleted  uint64
	DiscardRecoveryToken      string
	// RosterRevision advances whenever a contiguous authoritative journal reseed commits.
	// Unlike a row count or cursor it proves that even an empty roster at cursor zero arrived.
	RosterRevision uint64
	Sessions       []CachedSession           // journal-derived session model
	Snapshots      []Snapshot                // server-rendered terminal grids, latest per session
	PendingOps     []QueuedOp                // offline mutating ops awaiting replay (R-PHC.4)
	OpOutcomes     map[string]schema.Control // durable operation outcomes, keyed by operation id
	Stale          map[Bucket]bool           // buckets whose content may not be trusted until reconciled
	// StaleStreams are the REPAIR CHANNELS whose content may not be trusted (PB-SYNC-1).
	// It is a second set rather than a view over Stale because marking and clearing happen
	// at different granularities and one bit cannot carry both: a gap in the SHARED bucket
	// conservatively stales journal AND terminal (crypto.MailboxResult carries a bare Gap
	// bool with no frame kind, so the skipped seq cannot be attributed to either), while a
	// reseed repairs the journal alone and a fresh grid repairs the terminal alone
	// (PB-SYNC-2). With one flag per bucket, repairing either would present the other's
	// hole as live.
	//
	// It is DURABLE because Android SIGKILLs the app as routine behaviour: a flag held only
	// in memory comes back clear, and the phone presents a gap it already knew about as live
	// on the very next launch.
	StaleStreams map[string]bool
	// Items is the journal-derived TRANSCRIPT: interaction items folded by item_id
	// (interaction.go, docs/specifications/interaction-schema.md). It is content tier and
	// PURGEABLE -- a decrypted machine-sealed cache, the same class as Sessions and Snapshots,
	// and the most revealing of the three.
	//
	// It is DURABLE for the reason those two are, and the case is sharper than theirs: the
	// receive high-water is durable as well, so the relay's redelivery of the frames that
	// built the transcript is REFUSED on resume (crypto.ErrStaleSeq). A transcript held only
	// in memory is therefore DESTROYED by the process death Android hands out routinely rather
	// than re-fetched, and it takes with it any pending approval the machine is still blocked
	// on -- the one item IS-LIFE-3 exists to keep answerable across exactly that event.
	Items []Item
	// PushToken is the provider push token PB-STATE-9 assigns to the WAKE tier and
	// PB-PUSH-9 requires to survive process death and app upgrade: a token held only in
	// memory is re-registered only if the app happens to be foregrounded.
	PushToken string
	// PushPreference are PB-APP-7's two coarse toggles. PB-PUSH-10 makes the MACHINE
	// authoritative for suppression, but the phone still has to render the setting the
	// user chose across a restart, or the UI contradicts what the machine is doing.
	PushPreference PushPreference
	// ReconciledEpoch is the epoch whose machine-published rollback authorities
	// (PB-SYNC-7) have been ADOPTED. It closes the S7 recorded residual "reconcile
	// adoption is not persisted, so every phone process death re-arms the fail-closed
	// refusal of mutating ops, clearable only by a gateway reconnect the phone cannot
	// trigger" -- on Android process death is routine, so that residual is PB-STATE-10's
	// brick. The authorities themselves are already folded into the coordinates above;
	// this records that the fold HAPPENED, which nothing else can witness when every
	// authority is legitimately zero.
	ReconciledEpoch uint32

	// LastHeardAt is the newest AAD-COVERED IssuedAt this phone has ACCEPTED from the
	// machine, in unix milliseconds; zero means it has never heard from it (PB-APP-11).
	//
	// IT IS THE ONLY HONEST FRESHNESS CLOCK THE PHONE HAS. Every other staleness mechanism
	// here keys on a GAP, and a gap is observable only when a LATER seq arrives -- so a relay
	// that simply stops delivering the newest frames and keeps answering polls with an empty
	// page raises nothing: no gap forms, the poll SUCCEEDS, and Presence() asks the
	// withholding party whether the machine is alive (ADR-007 B121). IssuedAt is covered by
	// the AEAD, so the relay can only make a frame look OLDER by holding it, never newer:
	// the bound therefore fails closed under exactly the attack it exists for.
	//
	// It is DURABLE for the same reason StaleStreams is, and the case is the routine one:
	// Android SIGKILLs the app, the next launch renders the RESTORED caches, and a
	// freshness coordinate held only in memory comes back clear -- so the phone re-presents
	// content it already knew was old as live. It likewise survives the purge: it is a
	// record ABOUT content, not content.
	//
	// It is MONOTONIC. A retained frame delivered late must not move it backwards, and it is
	// clamped to the acceptance instant so a machine whose clock runs fast cannot buy itself
	// an unbounded freshness window with one stamp.
	LastHeardAt int64
	// contains filtered or unexported fields
}

State is every coordinate the phone must recover to resume after a process death, enumerated in ONE schema (PB-STATE-1) rather than scattered across side files.

SendSeq is keyed PER EPOCH because a revoke rotates the epoch and the phone -> machine stream then legitimately restarts at 1; carrying one epoch's ceiling into another would walk the counter away from the gateway's per-(sender,epoch) high-water. Receive is keyed per Bucket for the mirror reason.

type Store

type Store interface {
	Load() State
	Save(State) error
	// PurgeKeys is PB-KEY-7's REVOKE/UNPAIR purge: it destroys BOTH tier keys and everything
	// sealed under either of them, in memory and at rest, and RECORDS THE UNPAIR durably
	// (State.Disowned).
	//
	// The record is part of the purge rather than a caller's follow-up because nothing else can
	// witness it: every coordinate a pairing pinned is still there afterwards -- it has to be,
	// since one of them is what the durable blob is filtered on -- so a phone that destroyed
	// both tiers and kept quiet about it goes on describing itself as paired. That is
	// agents-tracker-d0b8, and the cost is not cosmetic: the presentation gate reads that
	// description, so the handset stays in the app shell and the pairing entry point is on a
	// screen it will not be shown.
	//
	// IT DESTROYS THE SEALED BLOBS, and the history of that clause is worth carrying because
	// it flipped twice. It first said so; ADR-007 B44 struck it, correctly, while the trigger
	// was a SCREEN LOCK -- nothing on the handset can re-obtain the epoch key and the replay
	// guard survives any purge, so the first lock would have landed the phone in PB-KEY-3's
	// terminal state. ADR-007 B133 deletes the lock and makes revoke/unpair the trigger, and
	// the same fact reads the other way: being unable to get back in without the machine is
	// what a revoke IS, and re-pairing mints fresh keys. The monotonic replay guards
	// (GrantEpoch/GrantSeq, WakeReplay) are the exception and survive; see dropAllKeyMaterial.
	// The clause is stated HERE and not only at the implementation because this interface is
	// what a SECOND implementation gets written against.
	//
	// It is a method rather than a Save of a State whose keys are zero because those two
	// are not the same act and custody cannot tell them apart from the bytes: a process that
	// came up on a push holds zeros for a content key it merely could not read, and Saves
	// constantly. One signal for both means either the purge does not reach disk or the wake
	// path destroys the epoch, and S14a shipped the first.
	//
	// The IN-MEMORY half must happen whether or not the durable half succeeds: zeroizing
	// cannot fail, PB-KEY-7 lists it first, and an implementation that gates it behind the
	// write keeps the keys live on a read-only data directory. A returned error therefore
	// means "the blobs at rest survived", never "nothing was purged" -- Load answers with
	// the purged state either way.
	PurgeKeys() error

	// UnsealContent re-opens the content tier IN PLACE, through the tier KEK: the way a
	// process that came up without the content tier -- the push/wake path, which holds the
	// wake key and never asks for the other (PB-KEY-2) -- restores content operations.
	//
	// It is NOT a way back from PurgeKeys, which destroys the blob it would open; after a
	// revoke it opens nothing and installs nothing. It must stay the ONLY path that installs
	// a content key from rest: one that answered from anywhere else would restore content the
	// sealer never released. A refusal is returned VERBATIM -- crypto.ErrKeyAuthRequired for
	// a tier the KEK declines to open, crypto.ErrKeyInvalidated for a destroyed KEK -- and
	// nothing is adopted unless every container opened.
	UnsealContent() error

	// RewindRelayCursor resets State.RelayCursor to zero durably, so the next drain re-reads
	// the mailbox from the beginning.
	//
	// IT IS A METHOD FOR THE SAME REASON PurgeKeys IS: custody cannot tell a rewind from an
	// ordinary Save of a State whose cursor happens to be zero, because a phone that has
	// never read anything holds zero too. mergeGuards raises RelayCursor monotonically -- it
	// is grouped with the replay guards -- so a Save can never lower it, and that is
	// deliberate: an ordinary writer arriving with a stale snapshot must not rewind the read
	// position. This is the ONE act that may, and it says so at the seam rather than by
	// slipping a zero past a merge rule.
	//
	// IT IS SAFE BECAUSE THE CURSOR IS NOT THE GUARD. The relay MINTS it (relay.Item.Cursor,
	// "untrusted ordering") and it is only an optimisation -- do not re-read what has been
	// read. What actually refuses a redelivered frame is the durable per-bucket seq
	// high-water (State.Receive) plus the grant watermark, both authenticated and both
	// untouched here, so re-reading the mailbox from zero replays nothing: every retained
	// frame is refused with crypto.ErrStaleSeq. The work is bounded by the relay's own
	// mailbox depth cap.
	//
	// IT IS THE ONLY RECOVERY FROM A POISONED CURSOR. A relay that rewrites one item's cursor
	// past every real one ends all machine->phone delivery permanently, durably, with every
	// indicator still reading live; before this seam the only way back was deleting the state
	// directory and re-pairing (ADR-007 B126).
	RewindRelayCursor() error
	SetRelayIncarnation(string) error
}

Store is the phone's durable custody of State. Load cannot fail: the blob is validated once at OpenStore, which fails closed, so a Store that was constructed has already proved its state readable (mirroring remotegw.InboundState).

func OpenStore

func OpenStore(path, machineID string, wake, content Sealer) (Store, error)

OpenStore opens the durable phone state at path, loading any previously persisted blob.

machineID is the machine these coordinates are only meaningful under. A blob stamped with a DIFFERENT one is not corrupt -- it simply describes coordinates that do not exist here -- so it loads EMPTY: `swarm remote init` regenerates the machine identity (epoch back to 1) and the re-paired phone must work, where a retained epoch-1 high-water would stale-drop its first frames and an error would refuse to start at all. An EMPTY machineID is an unpaired caller with no expectation, and adopts whatever the blob describes.

A missing file is first run. Anything unparseable or unversioned is ErrCorruptState and a newer schema is ErrFutureSchema: both fail closed, because starting from an empty checkpoint would leave the replay guard blind and re-open every retained frame.

machineID is also the INITIALISER, not only the filter, and that is load-bearing rather than tidy. Every path that ends with no blob adopted -- first run, and the different-machine discard just described -- used to leave State.Machine empty, and nothing downstream ever set it: the pairing handshake carries no endpoint id (pairing.MachinePayload has no such field), so mobile.App.pin has nothing to supply one from. The empty value was then PERSISTED, and this same filter discarded the whole blob on the next process start -- pairing, epoch, sealed content key, relay cursor and send-seq ceilings -- so a phone lost its pairing on the first Android process death, silently, and the "loads EMPTY, re-pair self-heals" path self-healed into the same state. Stamping it here rather than at any caller is what makes the filter and the value it tests come from ONE place; a store can no longer write a blob it will itself refuse.

wake and content are PB-KEY-2's tier KEKs and are REQUIRED for any real path: writing the epoch keys in the clear is the defect PB-SEC-1 names, so their absence is ErrNoSealer rather than a silent cleartext blob (ADR-007 B18(c)). An empty path persists nothing, so there is nothing at rest to seal and no sealer is needed.

type TakeControlInput

type TakeControlInput struct {
	Machine     string    // target machine endpoint id
	Session     string    // namespaced session id to take control of
	OperationID string    // durable client-generated idempotency key (single-use)
	ExpiresAt   time.Time // command validity horizon
	GateToken   string    // one-shot gate token bound via ContentHash and carried on the wire
}

TakeControlInput is the identity of a take_control op the phone authors. GateToken is hashed into the signed command (ContentHash = SHA256(GateToken)) AND carried on the wire, so the daemon can recompute the hash from the wire token and a relay that swaps the one-shot token breaks the signature.

type TerminalControlBeginInput

type TerminalControlBeginInput struct {
	Machine         string    // target machine endpoint id
	Session         string    // namespaced session id the generation targets
	SessionInstance string    // the INCARNATION the screen is showing; never empty
	Profile         int       // the remote profile version the phone selected
	OperationID     string    // durable client-generated idempotency key
	ExpiresAt       time.Time // command validity horizon
}

TerminalControlBeginInput is the identity of a terminal_control_begin op the phone authors (Wave R8, ADR-017 T6). SessionInstance and Profile are bound into the signature via TerminalControlBeginContentHash alongside the session.

type TerminalControlState

type TerminalControlState struct {
	// contains filtered or unexported fields
}

TerminalControlState is the phone's own view of which sessions it may currently type raw bytes into. It is deliberately NOT LeaseState (OPEN-C4): a generation is not a lease, they have different lifetimes and different ceremonies, and sharing the plane would make a fallback session compete for the shim's single interactive subscriber slot the owner already holds.

func NewTerminalControlState

func NewTerminalControlState(c *InputCoalescer) *TerminalControlState

NewTerminalControlState returns a state bound to the coalescer whose held bytes it must drop on every severance trigger.

func (*TerminalControlState) Apply

func (t *TerminalControlState) Apply(ctrl schema.Control, instance string, now time.Time)

Apply is the phone's ingestion of a command reply on the terminal-control plane, and it is what makes Begin reachable at all: without it the phone never learned the generation the machine minted, so TerminalInput could not name one and the whole control half was unreachable from the app whatever the wire allowed.

It lands beside LeaseState.Apply, on the SAME frame and for the same reason: a command reply is the only machine -> phone frame the phone can correlate to a send it made, and it has been through the AEAD, so the generation is adopted from an authenticated frame rather than from an unauthenticated header the untrusted relay could author.

A reply carrying no generation is not a begin, and nothing happens.

THE HORIZON IS RECOMPUTED HERE FROM THE PHONE'S OWN CLOCK, deliberately: the machine's wall is the one that decides, and this is only the banner's countdown. It is never longer than the machine's, because it starts later -- the reply arrives after the begin was stamped -- so the screen can say control ended early but never late.

func (*TerminalControlState) Background

func (t *TerminalControlState) Background(reason string)

Background is amendment T8-b: BACKGROUNDING SEVERS DIRECTLY, in its own right and independent of transport.

The phone core recorded the opposite -- that a backgrounded app loses its authority because backgrounding DISCONNECTS the phone and the transport loss is what severs. That answer is BY CONSEQUENCE and rests on a connectivity choice a later wave could revisit, at which point a generation would quietly outlive the screen that owns it. T6 makes "only the active foreground screen may send input" a ROUTING RULE; a generation with no screen displaying that it is live is the state the persistent control banner exists to make impossible.

func (*TerminalControlState) Begin

func (t *TerminalControlState) Begin(session, instance, generation string, expires time.Time)

Begin records a generation the machine confirmed.

func (*TerminalControlState) BindCoalescer

func (t *TerminalControlState) BindCoalescer(c *InputCoalescer)

BindCoalescer attaches the input coalescer whose held bytes every severance trigger must drop (ADR-017 T6-f). It is set by the facade that owns both, because the Core is constructed before the coalescer exists; a state with none still severs the generation, it just has no buffer to empty.

func (*TerminalControlState) Generation

func (t *TerminalControlState) Generation(session string) (generation, instance string, ok bool)

Generation returns session's live generation id and the instance it was minted against, so every input frame can name both and the machine can re-evaluate both per frame (T6-e). It reports false once the generation is severed, which is what keeps a refused byte from being authored at all.

func (*TerminalControlState) Live

func (t *TerminalControlState) Live(session string, now time.Time) bool

Live reports whether session has a live, unexpired generation at now.

func (*TerminalControlState) Sever

func (t *TerminalControlState) Sever(session, reason string)

Sever ends ONE session's generation and DROPS its held bytes (ADR-017 T6-f).

The bytes are dropped, never flushed, and each is recorded on the undelivered ledger. The two wrong answers are symmetrical and both ship a lie: a flush delivers bytes whose authority has just been withdrawn (an offline queue, one window long), and a silent drop leaves the user believing they typed something that never landed.

func (*TerminalControlState) SeverAll

func (t *TerminalControlState) SeverAll(reason string)

SeverAll ends EVERY session's generation and drops every held byte. It is the whole-device boundary: transport loss, the kill switch, or this device being revoked.

type TokenSource

type TokenSource func() (string, error)

TokenSource answers with the provider's CURRENT registration token at call time.

THE SHAPE IS THE FIX for the round-3 stale-rotation finding: two opaque token strings cannot be ordered by any phone-side rule, so a token taken as a snapshot BEFORE the registration lock can be installed durably by a caller that merely acted later than it read ("a phone silently never receives a wake"). EnsurePushRegistration therefore reads the token INSIDE the lock, at act time, from this source -- which is exactly what the Android callers hold: FirebaseMessaging.getToken answers with the current token, and onNewToken's argument is only ever the hint that it changed.

type TurnInterruptInput

type TurnInterruptInput struct {
	Machine      string    // target machine endpoint id
	Session      string    // namespaced session id the interrupt targets
	OperationID  string    // durable client-generated idempotency key
	ExpiresAt    time.Time // command validity horizon
	ExpectedTurn string    // the turn the phone rendered the Stop against; never empty
}

TurnInterruptInput is the identity of a turn_interrupt op the phone authors (Wave R6, Mirror M2.4). ExpectedTurn is the turn the SCREEN drew the Stop button against; both it and the session are bound into the signature via TurnInterruptContentHash.

type Undelivered

type Undelivered struct {
	Session string
	Bytes   int
	At      time.Time
	Reason  string
}

Undelivered is one accepted-but-not-delivered unit of input, kept so PB-INPUT-1's "delivery unknown / not sent" can be SURFACED. Bytes is how much the user typed (0 for a resize); At is read from the injected clock, never from a second clock authority.

type WakeDropCounts

type WakeDropCounts struct {
	// Total is every refusal, the counter WakeDrops has always reported.
	Total uint64 `json:"total,omitempty"`
	// Malformed is a payload refused on SHAPE, before the AEAD was touched (PG-WAKE-3):
	// wrong length, wrong version, or a type byte from another wake generation.
	Malformed uint64 `json:"malformed,omitempty"`
	// NoKey is the WAITING verdict: this phone holds no wake key for the address, either
	// because the pairing has not landed yet or because a forget erased the key.
	NoKey uint64 `json:"no_key,omitempty"`
	// Revoked is a wake for an address a machine-side revoke severed forever (PG-WAKE-14).
	Revoked uint64 `json:"revoked,omitempty"`
	// Unauthenticated is a BAD MAC: the envelope did not open under this address's key. A
	// forgery, a corrupted delivery, or a wake sealed under a key this phone has replaced.
	Unauthenticated uint64 `json:"unauthenticated,omitempty"`
	// Replay is an authenticated envelope at or below the per-address high-water.
	Replay uint64 `json:"replay,omitempty"`
	// Expired is an authenticated envelope older than WakeV1MaxAge: a stale capture, or a
	// delivery the provider sat on past the TTL. A future-dated one is NOT counted here
	// even though its error wraps ErrWakeExpired -- it has its own bucket below, which is
	// the whole point of the split.
	Expired uint64 `json:"expired,omitempty"`
	// PeerClockAhead is an authenticated envelope stamped further than wakeV1MaxFutureSkew
	// in the future. It is the diagnosis the finding asked for: a machine whose clock runs
	// ahead has EVERY wake refused, permanently, and this is the only number that says so.
	PeerClockAhead uint64 `json:"peer_clock_ahead,omitempty"`
}

WakeDropCounts is the refused-wake counter with enough structure for an operator to act on. It is the answer to "the wake path is dead and I cannot tell whether that is my machine's clock or an attacker": PeerClockAhead and Unauthenticated are different diagnoses with different remedies, and a single total distinguished neither.

The field set is CLOSED and every field is one refusal arm of AcceptWakeV1, so the sum of the arms is Total by construction. It carries no address, no key, no sequence number and no timestamp: it is read on a screen and written to a durable file, and a per-address breakdown would put the pairing topology in both.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL