Documentation
¶
Overview ¶
Package protocol implements the `runs.set_overrides` Protocol method the Console Playground page consumes.
`runs.set_overrides` records the reasoning-effort / temperature / max-tokens / system-prompt / additive-guidance override an operator applies to the NEXT message in a session. The override is:
- Session-scoped — keyed by the full identity triple `(tenant, user, session)`. A second session never sees the first's pending override (CLAUDE.md §6 — multi-isolation).
- One-shot — `Consume` removes and returns the pending override. The override applies to exactly the next `user_message` / `start` and is gone afterwards. It does NOT apply retroactively to past messages; a session that records an override then never sends a message simply drops it (documented behaviour — the phase plan's "next-message semantics" risk).
- BOUNDED — an unconsumed slot is reclaimed by capacity pressure, not by time. The Store holds at most DefaultMaxPendingOverrides slots and evicts the OLDEST-recorded slot to admit a new identity. See Store for why the bound exists and why the policy is drop-oldest.
The seam (CLAUDE.md §4.4) ¶
The Service depends on the in-process `Store` — a mutex-guarded map keyed by the identity triple. The Store is the V1 production implementation; it is deliberately a single concrete (the override slot is ephemeral per-runtime state, not a persistence-shaped subsystem with plausible alternate backends — there is no SQLite / Postgres override store, so §4.4's interface-plus-driver ceremony would be optional-capability smell). When a future durable / remote override store becomes a real requirement, it slots in behind a promoted interface; today the concrete is correct.
Identity is mandatory (CLAUDE.md §6 rule 9) ¶
Every method takes the wire request's `IdentityScope`. An incomplete triple fails closed with `ErrIdentityRequired` — there is no identity-downgrading knob. The Service NEVER reads identity from a package-level global; the triple flows in via the request.
Cross-session gating ¶
`runs.set_overrides` carries both an `IdentityScope` (the verified JWT identity) and `RunOverrides.SessionID` (the named target). The Service rejects a request whose `SessionID` names a session other than the verified `Identity.Session` with `ErrCrossSessionScope` — an operator cannot record an override for a session outside its own verified scope. Admin impersonation is out of scope for this method at V1 (the Playground records overrides for the operator's own session); the impersonation triplet on `IdentityScope` is honoured by the `user_message` / `start` consumer, not by override recording.
Concurrent reuse ¶
A constructed *Service is immutable after NewService and safe to share across N concurrent goroutines: it holds only the Store reference + an optional bus + redactor + logger + clock. Every method's per-call state lives in the call's arguments and locals, never on the Service. The Store guards its map with a sync.Mutex — the only mutable state, and it is documented "internally synchronised" per the carve-out.
Index ¶
Constants ¶
const DefaultMaxPendingOverrides = 4096
DefaultMaxPendingOverrides is the number of identity triples that may hold an unconsumed pending override at once. A slot is a handful of pointers, so the default is set for headroom over any plausible count of sessions concurrently mid-compose rather than to conserve memory: reaching it means slots are being recorded far faster than they are consumed, which is the abusive shape, not the operator one.
const DefaultMaxPendingOverridesPerTenant = 256
DefaultMaxPendingOverridesPerTenant is the number of slots ONE tenant may hold at once — one sixteenth of the global bound. It is the bound that makes the eviction policy's isolation claim true rather than merely intended: without it, the global bound alone means the caller who fills the map evicts every OTHER tenant's slot, continuously (see Store "Why a per-tenant sub-bound").
The ratio is what the guarantee is made of, so it is stated rather than left to be derived: at 4096 / 256 a single tenant can occupy at most one sixteenth of the map, so its churn can never reach the global bound and therefore can never displace a sibling tenant. Sixteen distinct tenants would have to act together to reach it — and a tenant id is a VERIFIED claim, not a caller-chosen string, which is the whole asymmetry the sub-bound rests on.
Variables ¶
var ( // ErrIdentityRequired — the request carried an incomplete identity // triple. RFC §5.5 / CLAUDE.md §6 rule 9 — fails closed. ErrIdentityRequired = errors.New("runs/protocol: identity scope incomplete") // ErrCrossSessionScope — the request's RunOverrides.SessionID named // a session outside the caller's verified Identity.Session. ErrCrossSessionScope = errors.New("runs/protocol: override targets a session outside the caller's verified scope") // ErrInvalidRequest — the override payload was structurally invalid // (an out-of-range temperature, a non-positive max-tokens, an // unknown reasoning-effort value). ErrInvalidRequest = errors.New("runs/protocol: invalid request") // ErrMisconfigured — NewService was called with a nil Store. ErrMisconfigured = errors.New("runs/protocol: NewService missing a mandatory dependency") )
Sentinel errors the Service returns. The wire handler maps each onto a canonical Protocol Code + HTTP status; in-process callers compare with errors.Is.
Functions ¶
func ComposeLLMOverrides ¶ added in v1.5.0
func ComposeLLMOverrides(session *PendingOverride, agent, tenant *planner.LLMOverrides) *planner.LLMOverrides
ComposeLLMOverrides merges the three per-run LLM-override layers into the planner's override bundle in precedence order **session › per-agent › tenant-wide baseline** (the run loop reads it at run start). Per field, a non-nil session value wins, then the per-agent (agent-config) value, then the tenant-wide baseline fills the rest. The session's SystemPromptOverride (full system-prompt REPLACE) is session-only; the per-agent layer carries sampling parameters only (model / temperature / max-tokens / reasoning-effort) — never prompt text. Returns nil when no layer set anything. config defaults are applied last by the planner's applyLLMOverrides (an unset field leaves the request untouched).
ExtraInstructions keeps its producer's authority ¶
The tenant-wide ExtraInstructions value remains trusted additive guidance. The session field of the same wire name needs only a verified identity and therefore lands in UserPersonalization instead of being concatenated into the tenant string. Keeping the values separate lets the prompt builder escape and label the lower-authority contribution structurally. A session value can neither replace nor clear tenant guidance.
This is the ONE production composition; cmd/harbor's run loop, the devstack twin, and the integration test all call it (no re-implemented copy — CLAUDE.md §17.4).
Types ¶
type Clock ¶
Clock is the time source the Service stamps RecordedAt / AppliedAt from. Injected so tests pin a deterministic instant (CLAUDE.md §11 — time-sensitive tests use a controllable clock).
type Option ¶
type Option func(*Service)
Option configures NewService.
func WithBus ¶
WithBus wires the canonical events.EventBus the Service publishes the `runs.overrides_set` audit event onto. A nil bus is treated as "WithBus not supplied" — the override recording is then logged at Info instead of published (the action is NEVER fully silent — CLAUDE.md §13).
func WithClock ¶
WithClock injects the time source. Defaults to time.Now. Tests pin a deterministic instant.
func WithLogger ¶
WithLogger sets the slog.Logger. A nil logger routes to slog.Default().
func WithRedactor ¶
WithRedactor wires the audit.Redactor. The `runs.overrides_set` payload is a SafePayload by construction (it carries no caller-supplied bytes — only identity + boolean flags), so the bus bypasses the redactor for it; the redactor is held for parity with the other Console-page services and for any future non-safe emit.
func WithValidModels ¶ added in v1.5.0
WithValidModels wires the set of model names that have a configured `ModelProfile`. When supplied (non-empty), a `Model` override naming a model outside the set is rejected at set time with ErrInvalidRequest (fail loud, mirroring the tenant-default layer). When unsupplied, the Service does not validate the model name — an unknown model surfaces at the LLM safety edge instead.
type PendingOverride ¶
type PendingOverride struct {
// ReasoningEffort, when non-nil, is the validated reasoning-effort
// hint for the next message.
ReasoningEffort *string
// Temperature, when non-nil, is the validated sampling temperature.
Temperature *float64
// MaxTokens, when non-nil, is the validated per-message token ceiling.
MaxTokens *int
// SystemPromptOverride, when non-nil, replaces the agent's system
// prompt for the next message only.
SystemPromptOverride *string
// ExtraInstructions, when non-nil, is the one-run user personalization
// input. ComposeLLMOverrides keeps it separate from tenant guidance so
// the planner can render it in a lower-authority escaped section. An
// empty or whitespace-only value contributes nothing and is not an error.
ExtraInstructions *string
// Model, when non-nil, is the validated model the next message's run
// requests (the session-level model swap).
Model *string
// RecordedAt is the runtime instant the override entered the slot.
RecordedAt time.Time
}
PendingOverride is the recorded, validated override held in a session's one-shot slot. It is the internal projection of a types.RunOverrides — the same pointer-optional fields, plus the recording instant.
A PendingOverride is immutable once stored; Consume returns it by value and removes the slot.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service implements `runs.set_overrides`. It validates the override payload, enforces identity, records the override into the Store, and emits the `runs.overrides_set` audit event.
The Service is a compiled artifact: immutable after NewService; every method's per-call state lives in arguments + locals.
func NewService ¶
NewService builds the `runs.set_overrides` Service over an override Store. store is mandatory — a nil fails loud with ErrMisconfigured rather than building a Service that would nil-panic on the first request (CLAUDE.md §5).
The returned *Service is immutable after construction and safe for concurrent use by N goroutines.
func (*Service) SetOverrides ¶
func (s *Service) SetOverrides(ctx context.Context, req prototypes.RunSetOverridesRequest) (prototypes.RunSetOverridesResponse, error)
SetOverrides records the next-message override carried by req. It validates identity, cross-session scope, and the override payload, writes the validated override into the Store, emits the `runs.overrides_set` audit event, and returns the recording instant.
The override applies to the NEXT message in the session — it is not retroactive. SetOverrides does not touch any past message.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the in-process, identity-scoped pending-override slot map. It is a compiled artifact: constructed once via NewStore, shared across N goroutines, with its mutable fields — the slot map and its recording-order list — guarded by an internally-synchronised sync.Mutex.
The map is keyed by the identity triple so a session's pending override is invisible to every other `(tenant, user, session)` — multi-isolation is enforced by the key, not by a post-fetch filter.
The bound, and its drop policy (CLAUDE.md §5) ¶
A slot is written by `runs.set_overrides` and removed by the Consume the next message performs. A session that records an override and then never sends a message leaves its slot behind, so an UNBOUNDED map grows with the count of such sessions for the lifetime of the process — an availability defect any authenticated caller reaches by recording an override under a fresh session id in a loop. The Store therefore holds at most MaxSlots entries.
The policy is DROP-OLDEST, WITHIN THE ADMITTING TENANT, and it is stated here because a silent eviction would itself be the §13 silent-degradation shape:
- Evicting rather than REFUSING the write. A capacity refusal denies the surface to the caller that asked for it, and the slot it would have refused is the one that just expressed intent. Eviction spends an ABANDONED slot instead. This is a trade between two costs, not a containment argument — see the next bullet for what actually confines the damage.
- A PER-TENANT sub-bound underneath the global one. This is the bullet that carries the isolation property, and it exists because the global bound ALONE does not: with one process-wide order list, a tenant recording overrides under fresh session ids evicts every other tenant's slot, continuously, for as long as it keeps writing. That is the very cross-tenant availability defect the first bullet is often read as ruling out, and eviction does not rule it out — the sub-bound does. A tenant at MaxSlotsPerTenant evicts ITS OWN oldest slot, so its churn is self-inflicted and reaches no sibling.
- OLDEST-recorded rather than newest. A slot's whole purpose is to be consumed by the very next message, so the longer one has sat unconsumed the more likely it is already abandoned. Dropping the newest would instead discard the write of the caller who just asked for it, while retaining slots nothing will ever read.
- LOUD, once per eviction. Every eviction logs at Warn with the evicted triple, the instant the slot was recorded, and WHICH bound forced it — a tenant evicting itself and a tenant being displaced by the global bound are different operator situations and must not read alike. There is no first-in-window suppression: below the bounds an eviction is impossible, so a line here is never routine, and suppressing the second line would hide the scale of whatever is producing them.
- No TTL. The slot already has a lifetime ("until the next message"); a second, time-based expiry axis would be a second mechanism answering the same question, needing its own clock and sweeper. The bounds alone close the growth defect.
Why a per-tenant sub-bound, and what it does NOT claim ¶
The asymmetry that makes the sub-bound work: a SESSION id is a caller-chosen string, unbounded and free to mint, which is exactly why the growth defect existed. A TENANT id is a verified claim on the request identity. So bounding per tenant bounds the axis an attacker controls by the axis it does not.
Two residuals are stated rather than engineered away:
- Enough DISTINCT tenants acting together still reach the global bound, and the slot evicted there does belong to another tenant. That needs MaxSlots / MaxSlotsPerTenant separately-authenticated tenants (sixteen at the defaults), not one caller with a loop.
- Inside one tenant, a user can still displace a sibling user's slot. The sub-bound is keyed on the tenant because that is the boundary the isolation claim names and the outermost one CLAUDE.md §6 makes integrity-critical; a second sub-bound axis would be a second mechanism answering a question nobody has reported, and the per-tenant bound must remain the outer one in any case or a tenant with many users would exceed its share of the global map.
An evicted slot is indistinguishable to its session from one that was never recorded: the next message runs with no override, exactly as the documented "recorded, then never sent" path already behaves.
func NewStore ¶
func NewStore(opts ...StoreOption) *Store
NewStore builds an empty override Store bounded at DefaultMaxPendingOverrides slots globally and DefaultMaxPendingOverridesPerTenant slots per tenant. The returned *Store is safe for concurrent use by N goroutines.
func (*Store) Consume ¶
func (s *Store) Consume(id identity.Identity) (PendingOverride, bool)
Consume removes and returns the pending override for id. The second return is false when the identity triple has no pending override — the common case (most messages carry no override). Consume is the one-shot read: the slot is empty after a Consume until the next Set.
This is the seam the `user_message` / `start` consumer calls at the start of the next message in a session.
func (*Store) Peek ¶
func (s *Store) Peek(id identity.Identity) (PendingOverride, bool)
Peek returns the pending override for id WITHOUT removing it. Used by tests and read-side projections that must not consume the slot.
func (*Store) Set ¶
func (s *Store) Set(id identity.Identity, po PendingOverride)
Set records po into the slot for id, replacing any prior pending override for that identity triple. An operator that records two overrides before sending a message keeps only the second — the slot is last-write-wins, the documented behaviour.
A re-Set REFRESHES the identity's position in both recording orders, so the most recently expressed intent is the last to be evicted.
Admitting a NEW identity consults the per-tenant bound FIRST: a tenant already holding MaxSlotsPerTenant slots evicts its OWN oldest, so a caller churning session ids can only ever displace itself. Only when the admitting tenant is under its own bound and the map is at MaxSlots does the global eviction run. See Store for the policy, why it is not a refusal, and the two residuals it does not claim to close.
type StoreOption ¶ added in v1.25.0
type StoreOption func(*Store)
StoreOption configures NewStore.
func WithMaxSlots ¶ added in v1.25.0
func WithMaxSlots(n int) StoreOption
WithMaxSlots bounds the number of identity triples that may hold a pending override at once. A non-positive value is ignored (the default stands) — there is no way to configure the bound AWAY, because an unbounded slot map is the defect this bound exists to close.
func WithMaxSlotsPerTenant ¶ added in v1.25.0
func WithMaxSlotsPerTenant(n int) StoreOption
WithMaxSlotsPerTenant bounds the number of slots ONE tenant may hold at once. A non-positive value is ignored, for the same reason WithMaxSlots ignores one: the sub-bound is what confines an evicting caller's damage to its own tenant, so there is no way to configure it away.
A value above the global bound is CLAMPED to it by NewStore rather than honoured, because a per-tenant bound the global bound reaches first can never fire and would read as a guarantee that is not there.
func WithStoreLogger ¶ added in v1.25.0
func WithStoreLogger(l *slog.Logger) StoreOption
WithStoreLogger sets the slog.Logger the Store reports evictions on. A nil logger routes to slog.Default().