M2-13: Run context with origin and trust¶
Status: ready | Depends on: M2-11, M2-12 | Milestone: M2
Goal¶
After this ticket an agent adapter loads the context of its own active run with run.context.read (getRunContext, GET /api/v1/runs/{run_id}/context) and receives run_context.v1: the triggering message complete and never cut, the earlier messages of the thread above the floor of its session, on the first run of a session the main-thread messages before the thread's parent message, and for every element its author, origin, kind, channel, role and a trust marker trust (trigger, authorized, self, unverified, system) that the server computes at the time of the request from the current rights. Contents are bounded by the channel's history_backfill_limit, by agent.context_message_max_bytes per message and by agent.context_max_bytes in total, cut from old to new. The server builds this context because only authz knows which authors may instruct the agent (ADR 0054, R-AG-06, D-AG-05); every adapter then passes unverified content to its runtime only as marked data, so instructions hidden in messages of people without trigger, of other agents, apps or imports cannot pass as the request of the triggering person (SEC-AI-1, T-AI-01, T-AI-03, RK-01). This is the server half of AG-20 and AG-21; the SDK formatting helper (M2-29), the reference adapters (M2-33, M2-34) and the conformance suite (M2-31) build on it, and it replaces reading the whole event stream in adapters (RK-19).
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal; Scope; Out of scope: files and voice messages in agent calls are M3; Exit criteria 1 and 10; Test strategy: fake agent, FakeServer, no model costs; Red team first paragraph: T-AI-01 to T-AI-09 after OWASP, "confused deputy through the server API"; Risks: prompt injection), block "agents and extensions" rows M2-09 to M2-14, M2-29, M2-31, M2-33, M2-34; section 9 rows M3-07 and M3-09; section 7 rows M1-57, M1-62, M1-69.docs/spec/09-agents.md: 1 "Principles" (points 1 to 4); 3.2 "Origin (messages.origin)"; 3.3 "Channel switches" (history_backfill_limit); 3.4 "Decision per message" point 5 (auto thread); 3.6 "Guest agents" (no session); 3.7 "Auto-thread from a channel message" (M stays in the main thread, the run belongs to the new thread); 4.1 "Overview" (context by REST); 4.4 "Events to the adapter" (payloads carry no content); 4.6 "Run context" completely; 4.9 "Order in the adapter: commitment first, then runtime" (context read beforerun.start); 5.1 "State machine"; 7 "Follow-ups during a run"; 9.1 "Lifecycle" (session.modenew,resume,bind); 10.4 "Execution" (command runs have no triggering message); 16.1 "Test doubles"; 16.2 "Required scenarios" (AG-20, AG-21, AG-28); 17.6 "Flow of an invocation" (how an adapter formats the context); 19 (R-AG-04, R-AG-06); 21 "Configuration values of this chapter".docs/spec/03-data-model.md(core contract, read only):runs(thread_id,agent_id,agent_session_id,trigger_kind,triggering_message_id,triggered_by,state,queued_at, the partial index of active runs, R-DM-14);run_followups(state);agent_sessions(is_current,started);threads(kind,parent_message_idand the sentence about moves,operator_agent_id);messages(thread_seq,author_principal_id,origin,kind,content_md,reply_to_message_id,deleted_at,system_code; "Deletion emptiescontent_md");channels(history_backfill_limit, effective operator).docs/spec/05-actions.md(core contract, read only): 1 rules 1 and 2; 2.1 (agent-self); 2.3 points 1, 4, 5 and 6; 2.4 (column "Audit", denials are always audited); 2.5; 4.10 rowrun.context.read.docs/spec/04-sync-protocol.md(core contract, read only): 8.3 point 5 (a loss oftriggercancels the starter's runs); 15.3 "Invocation and run start" point 4 (the adapter reads the context before it starts); 15.4 "Follow-ups".docs/spec/07-permissions.md: 2.9 "Agents as principals"; 3 "Principal context"; 4.1 (reasonsno_grant,not_self,subject_kind_not_allowed); 4.2 "Flow" (filter mode, audit of denials, "no permission logic outside ofauthz"); 4.3 "Visibility"; 4.5 "Archived spaces and channels" point 1; "SR9 Acting-for binding (confused deputy)"; 6 "Read horizon of agents"; 7.2 last paragraph ("How agents read threads is decided by milestone M2"); 7.4 "Query pattern" rules 1 and 3; 11.2 AZ-P1, AZ-P4, AZ-P12.docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rowsruns,messaging,chat,agents,authz); 4.4 "Allowed dependencies" (rowruns:instance,chat,agents,messaging,billing; noauthz, noidentity, nofiles); 4.5 "Cross-cutting ports" (kernel/authorize); 5 "Layers within a module"; 9.2 "404 instead of 403"; 9.3 "Proof of the check".docs/spec/06-api.md: 4 "Authorization in the API"; 5 "Errors" (not_found,forbidden,internal_error; point 7, the closed code list); 16 "Endpoints", table "Runs" rowgetRunContext.docs/spec/19-security.md: 9 "Logs without secrets and without content"; 11 "Controls for agents" (SEC-AI-1, SEC-AI-3); SEC-T10 in the test table.docs/spec/20-privacy.md: 6 "Agents, LLM providers and processors" (Data minimization at invocation: thread,history_backfill_limitearlier messages, authors as handle and kind, no e-mail addresses).docs/api/openapi.yaml: operationgetRunContext(parameterRunIdPath, responsesUnauthorized,Forbidden,NotFound,TooManyRequests,Error); schemasRunContext,RunContextMessage,RunContextAttachment,PrincipalRef,MessageOrigin,MessageKind,Message,Attachment,File,AgentInvocationPayload(message_iddescription),AgentFollowupPayload(message_iddescription),AgentSession,Problemwithx-problem-codes.- ADRs: 0054 (complete, including "Confirmation"), 0017 (one authorization function, 404 instead of 403), 0022 (agents outside the server), 0025 (run as an object), 0053 (no JSON
null), 0013 (spec first), 0008 (module rules). docs/DECISIONS.md: D-AG-05, D-K-D6, D-W-100, section 10 "Rulings of the build milestones" (row formatD-M2-13-<n>).docs/security/threat-model.md: T-AI-01, T-AI-02, T-AI-03 and the STRIDE row "Prompt injection through content".docs/RISKS.md: RK-01, RK-19.docs/OPEN-QUESTIONS.md: no item concerns this ticket.docs/operations/configuration.md:agent.context_max_bytes,agent.context_message_max_bytes,channel.history_backfill_limit_default,channel.history_backfill_limit_max,audit.deny_aggregate_window.docs/observability/signals.md:bothy.authz.errorswithbothy.reason = filter_mismatch;docs/observability/alerting.md:BothyAuthzError. No metric is named for the run context; this ticket adds none.- M1 briefs whose interfaces this ticket uses:
docs/briefs/M1-28-authz-core.md(Authorizer.Authorize,ResourcewithOwnerID, atomAgentSelf, filter modeauthorize.Filter(ctx),Decision.Err, snapshots without credentials forViewAsandActingFor, visibility of kindrun),M1-18-http-chain.md(check log: responses of status 400 or higher pass without a check),M1-17-api-server-stubs.md(api/operations.yaml, rowgetRunContextunderlater),M1-24-audit-chain.md(recorder),M1-44-identity-api-tokens.md(agent tokens),M1-51-agents-records.md,M1-54-chat-operators.md(effective operator),M1-55-chat-threads.md(Threads.Info,ThreadInfo),M1-56-messaging-send.md(Messagesport, the single representation function andMessageView, origins),M1-57-messaging-history.md(window queries, tombstone representation,TestQueriesCarryVisibleChannelPredicate),M1-62-chat-thread-move.md(moveThread,parent_message_idkeeps pointing into the source channel),M1-69-canary-test.md(services/server/test/canary,services/server/test/harness),M1-07-config-schema-loader.mdandM1-08-config-reference.md(keys ininternal/wire/configkeys_<module>.go, chapter constants inconfigchapters.go),M1-12-postgres-migrations.md(pgtest). - M2 briefs this ticket builds on:
docs/briefs/M2-02-channel-behavior-config.md(history_backfill_limit, chat portAgentConfig),M2-04-trigger-decision.md,M2-05-runs-state-machine.md(portRuns, run descriptor, states,runstesthelpers),M2-07-agent-consumers.md(pollAgentEvents),M2-08-invocation-delivery.md,M2-09-run-lifecycle.md(ruling:OwnerIDof a run is the principal the action reserves it to,runs.agent_idfor agent-self actions; test world),M2-11-run-followups.md(run_followups),M2-12-agent-sessions.md(agent_sessions, portagents.Sessions), and the neighbourM2-14row (SR9,acting_for). - Code:
services/server/internal/runs(README, port,app,domain,adapters),internal/messaging,internal/chat,internal/agents,internal/authz,internal/kernel/authorize,internal/kernel/actor,internal/kernel/errs(errs.New,errs.CodeNotFound,errs.CodeInternalError),internal/kernel/ids(ids.ID),internal/wire(configkeys_*.go,configchapters.go),api/operations.yaml,test/harness,test/canary. Read the merged state withgit logbefore you start. - Implied dependencies: the header names M2-11 (
run_followups) and M2-12 (agent_sessions). Their closure brings M2-10, M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02, M1-65, M1-63, M1-61, M1-56, M1-55, M1-54, M1-49, M1-44, M1-28 and everything below them. M1-69 is required by the brief of M2-02 (so it, M1-71 and M1-62 are merged once M2-02 is); see Boundaries.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
run.context.readis reached only throughgetRunContext(GET /api/v1/runs/{run_id}/context): rightagent-self, only for own active runs (statesqueued,running,waiting_input); rate-limit classrl.agent_runtime; Auditno, Step-upno, Permissionsno; denials are audited byauthzlike every denial of a request (05 4.10 and 2.4, R-AZ-04; 06 16 "Runs"; openapigetRunContext).- The server builds
run_context.v1; the adapter never derives trust itself and no rights logic exists outsideauthz(09 4.6, ADR 0054 decision drivers, R-AG-06, 05 rule 1.1, ADR 0017). - Parts:
trigger_messageis the triggering message, complete, with its attachments and never cut;thread_contextholds messages of the thread T before M withthread_seqabovecontext_floor_seq, newest kept, at mosthistory_backfill_limit;channel_contextexists only on the first run of a session in a thread withparent_message_idand holds main-thread messages before the parent message, at mosthistory_backfill_limit;truncated_before_seqis set when older messages are missing;context_floor_seqis thethread_seqof the triggering message of the previous run of the same session, otherwise zero (09 4.6, ADR 0054 point 5). - In a session with
session.mode = resumethe agent's own messages are left out; fornew,bindand guest runs they are included. Messages already delivered to this session as follow-ups (run_followupsindeliveredorabsorbed) carryalready_delivered: true(09 4.6, 09 9.1, 09 3.6). - Contents above
agent.context_message_max_bytesare cut withtruncated: true; the sum stays withinagent.context_max_bytes, withouttrigger_message; cutting goes from old to new (09 4.6, 09 21, AG-21). - Every element carries
message_id,thread_seq,created_at,author(id,kind,handle,display_name),role(userfor people,assistantfor this agent,otherfor other agents, apps, imports and system),origin,kind,content_md,attachments(file_id, name, MIME type, size,scan_status, transcript when present),reply_to_message_idandtrust; plus its channel (Design note 14, ruling 10) (09 4.6, 19 SEC-AI-1, 20 section 6). trustis set at the time of the request:triggerfor the triggering person,authorizedfor another active person with effectivetriggeron the channel,selffor this agent,unverifiedfor people withouttrigger, other agents, apps and imports,systemfororigin = system. A person who losttriggerbetween two runs isunverifiedin the second context (09 4.6, ADR 0054 point 2 and "Confirmation").- File contents are always data and are fetched by the adapter with
file.read; a transcript of a voice message carries the trust of its message (09 4.6, ADR 0054 point 4). This ticket lists attachments as the representation of messaging returns them; real attachments and transcripts arrive with M3. - No read right gives
404, and an unknown run, a foreign invisible run and an own run that is no longer active answer exactly the same404(05 rules 1 and 2, R-AZ-01, 02 9.2, AZ-P4). - No message content, handle or display name appears in logs, traces, metrics, audit details or error responses (19 section 9, SEC-T10); no JSON
nullin the response (ADR 0053).
Scope¶
All paths under services/server/ unless noted. Names in code blocks are proposals; follow the style of the merged module where it differs and list the differences in the report.
Create:
internal/runs/domain/runcontext.go: the pure rules of Design notes 4 to 8: typesTrust(trigger,authorized,self,unverified,system) andRole(user,assistant,other);Classify(m AuthorFacts, p RunParties, holders map[ids.ID]bool) (Role, Trust);SessionMode(new,resume,bind,guest) andPlanContext(f PlanFacts) ContextPlan(floor, own messages excluded, channel context wanted);CutContent(s string, maxBytes int) (string, bool);FitBudget(channel, thread []Item, maxTotal int) (keptChannel, keptThread []Item);TruncatedBeforeSeq(...). Imports onlykernel-corepackages (02 section 5).internal/runs/app/runcontext.go: the use caseReadRunContext(Design note 1).internal/runs/adapters/postgres/queries/runcontext.sql(sqlc; regenerate):GetRunForContext,PreviousSessionTrigger,DeliveredFollowupsOfSession(Design notes 5, 9 and 11).internal/runs/adapters/httpapi: the strict-server methodGetRunContextin the module's handler file.internal/messaging/app/context.goandinternal/messaging/adapters/postgres/queries/context.sql:ListContextWindow,GetContextMessage(Design note 3).internal/kernel/authorize/triggerholders.go: the interfaceTriggerHolders(Design note 7).internal/authz/app/triggerholders.goimplementing it, with its fake ininternal/authz/adapters/fake.internal/wire/configkeys_runs.go(or the existing file that already holds keys of chapter 09):agent.context_max_bytesandagent.context_message_max_bytes(Design note 12).test/integration/runcontext/(componenttestsupport, harnesstest/harness): the integration tests below.
Change:
internal/runsport:Runs.Context(ctx context.Context, runID ids.ID) (RunContext, error)with the DTOsRunContext,ContextMessage,ContextAttachment(Design note 10). The acting principal comes fromkernel/actorin the context, never from a parameter.internal/messagingport:Messages.ContextWindow(ctx, ContextQuery) (ContextPage, error)andMessages.ContextMessage(ctx, messageID ids.ID) (MessageView, bool, error)(Design note 3).internal/chatport, only where missing:ThreadInfo.ParentMessageID(zero when absent); the internal read of the channel switches includingHistoryBackfillLimit(Design note 2).internal/agentsport, only where M2-12 did not export it: the read of the current session of a thread (id,agent_id,started) (Design note 5).internal/wire: hand theauthzimplementation ofauthorize.TriggerHolderstoruns; constructReadRunContextwith the two configuration keys; add the chapter constant for09-agents.mdtoconfigchapters.goif missing.api/operations.yaml: movegetRunContextfromlaterto the enabled list; rerun the route generator.docs/api/openapi.yaml:RunContextMessage.channel_id(required); descriptions ofgetRunContext,RunContext.channel_context,RunContext.truncated_before_seq,RunContextMessage.truncatedandalready_deliveredafter Design note 10; the example ofgetRunContextwithchannel_id.docs/spec/09-agents.md4.6 (English, like the chapter): the element fieldchannel_idand the rules of rulings 3 to 9 and 12;docs/spec/02-architecture.md4.5, rowkernel/authorize: the portTriggerHolders(ruling 1).test/canary/scenario.goandtest/canary/README.md: the context read in the scenario (a newly enabled operation that is missing there fails the canary test, M1-69).TestQueriesCarryVisibleChannelPredicateof messaging (M1-57) and, if present, the repository-wide list oftools/lint/sql_visibility.py(M1-70): the new queries with their reason (Design note 11).docs/operations/configuration.md: regenerated withmake config-reference.- READMEs of
runs,messaging,chat,authz(andagentsif its port changed); package doc ofkernel/authorize;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10.
This ticket creates no migration and no new top-level Makefile target.
Out of scope¶
- SR9:
acting_forfrom the invocation token inAuthorizeandScopeand its per-request intersection with the rights of the triggering person (M2-14). This ticket keeps every element inside the thread's current channel (Design note 6), the channel on which the triggering person neededtriggerfor the run; a loss of that right cancels the run (R-SY-11, M2-09). - Formatting the context with trust markers for a runtime: the SDK helper (M2-29), the reference adapters for Hermes Agent and the Claude Code runner and their AG-20 adapter tests (M2-33, M2-34).
- The data-driven scenarios AG-20 and AG-21 with the fake agent in the conformance suite (M2-31).
- Real attachments, file contents,
file.readand transcripts of voice messages (M3-05, M3-07, M3-09; the plan names no ticket that fills them into the run context, reported as a plan gap). - Follow-ups and their states (M2-11), agent sessions and their API (M2-12), run start, renewal and end (M2-09), delivery of
agent.invocation(M2-08). - The context of runs with
trigger_kindcommandorinteraction(M2-16, M2-15; reported as a spec gap) and the content of follow-ups of the current run (M2-11; reported as a spec gap). - Channel prompt and skills (
agent.config.read, M2-02). - The timing part of AZ-P4 across all routes, the repository-wide SQL lint (M1-70) and the Schemathesis contract tests (M1-71).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-13-run-context; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(runs): ...,feat(messaging): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - Start check:
make checkis green on the fresh worktree, elseVerdict: BLOCKED base branch red. M2-11 (run_followups), M2-12 (agent_sessions), M1-62 (moveThread) and M1-69 (test/canary) are merged, elseVerdict: BLOCKED dependency <ticket> missing: this ticket reads their tables and paths (the header names M2-11 and M2-12; M1-62 and M1-69 are in its closure). - No agent adapter, agent runtime, model or third-party service takes part in any test: the tests play the adapter with an agent token over HTTP (
pollAgentEvents,getRunContext); real runtimes run only behind an explicit switch (09 16.1), and this ticket has none. - Configuration values in tests are set through the named keys of the test harness; no limit is hard-coded in production code.
Design notes¶
-
Use case
ReadRunContext(Runs.Context), a read without a unit of work and without locks; every part is recomputed on each request (ruling 13). Order: a. Load the run withGetRunForContext(id,thread_id,agent_id,triggered_by,state,trigger_kind,triggering_message_id,agent_session_id,queued_at). An unknown id returns the error value of anot_founddecision (Decision.Err()of M1-28), so status, body and headers equal an invisible run (02 9.2). Without anauthorizecall this is allowed because the response status is 404 (M1-18 check log). b.Authorize(ctx, p, run.context.read, Resource{Kind: run, ID: run.id, ThreadID: run.thread_id, OwnerID: run.agent_id}).OwnerIDis the agent becauserun.context.readis agent-self (05 2.1 "own run"; ruling of M2-09 note 1); reuse the descriptor helper of M2-09 if it exists. M1-28 decides: thread not readable gives404; readable by another agent gives403 forbiddenwith reasonnot_self; a person or an app gives403 forbiddenwithsubject_kind_not_allowed. Every non-allow returnsDecision.Err();authzaudits the denial (aggregated peraudit.deny_aggregate_window). An allow writes no audit entry (Auditno). c.statenot inqueued,running,waiting_input: the same404as in step a (ruling 4).queuedis active: the adapter reads the context beforerun.start(04 15.3 point 4, 09 4.9, AG-28). d.trigger_kindother thanmessage: an internal error (internal_error), logged with run id and trigger kind only. Such runs cannot exist before M2-15 and M2-16, andrun_context.v1has no place for their trigger (spec gap); amessagerun whosetriggering_message_idis NULL (the message row is gone) gives the404of step a. e. Thread facts throughchat.Threads.Info(run.thread_id): C = the thread's current channel,Kind,ParentMessageID,OperatorAgentID; for a main thread the channel's effective operator (M1-54). f. Session facts and plan (note 5). g. The triggering message M throughmessaging.Messages.ContextMessage(note 3). Missing or not readable by the agent: the404of step a (ruling 12). M is in T, or M is the parent message of T (auto thread, 09 3.7). h.thread_contextthroughMessages.ContextWindow(note 6),channel_contextwhen the plan wants it (note 6). i. Cut and budget (note 8), thentruncated_before_seq(note 8). j. Trust (note 4): collect the distinct authors of the kept elements and of M withorigin = humanother thantriggered_by, callTriggerHolders.WithTrigger(ctx, Resource{Kind: channel, ID: C}, authors)once (note 7), classify every element. k.already_delivered(note 9). l. BuildRunContext(note 10). The handler answers200with the schemaRunContext. Any source error (database,TriggerHolders) ends the request with that error's kind; it never turns into a context with weaker trust (fail closed, 09 1 point 4). -
Where each fact comes from (ports only, 02 4.4 row
runs):Fact Source run row, previous run of a session, follow-up states this module's queries (note 11) C, thread kind, parent message, fixed operator chat.Threads.Info(M1-55; addParentMessageIDtoThreadInfoif missing)effective operator of C the chatport of M1-54 (as M2-05 uses it)history_backfill_limitof Cthe internal, non-authorizing read of the channel switches that M2-05 uses for the trigger decision (M2-04 Switches); extend it byHistoryBackfillLimitif missing. If no such read exists, addChannelSwitches(ctx, channelID ids.ID) (ChannelSwitches, error)tochat.AgentConfig; it never returns the prompt or the skills (R-AZ-09)current session of T ( id,agent_id,started)agents.Sessions(M2-12)messages with author PrincipalRef, origin, kind, attachmentsmessaging(note 3);runsmay not useidentityorfileswhich authors have triggeron Ckernel/authorize.TriggerHolders(note 7) -
Messaging port additions (the queries live in
messaging, the owner ofmessages, 03 principle 12):// ContextQuery selects messages of one thread for a run context (09 4.6). The caller has // authorized run.context.read on the run of this thread in the same request. type ContextQuery struct { ThreadID ids.ID AfterSeq int64 // exclusive lower bound (context_floor_seq, or 0) BeforeSeq int64 // exclusive upper bound (thread_seq of M or of the parent message) Limit int // keep the newest Limit messages; 0 returns none ExcludeAuthorID ids.ID // zero: none; the agent itself in session mode resume } // ContextPage holds the selected messages ascending by thread_seq with full content. type ContextPage struct { Items []MessageView HasOlder bool // a selectable message with AfterSeq < thread_seq < first item exists } // ContextWindow returns the window; tombstones (deleted_at set) are never selected. ContextWindow(ctx context.Context, q ContextQuery) (ContextPage, error) // ContextMessage returns one message with full content (tombstones as stored); ok is false // when it does not exist or the actor may not read it. ContextMessage(ctx context.Context, messageID ids.ID) (MessageView, bool, error)Both use the single representation function of M1-56 with the full
content_md(nevermessage.list_content_max_bytes), check every returned item withauthorize(actor, message.read, message)in filter mode (authorize.Filter(ctx)), and drop a denied item. InContextWindowa denied item is a filter mismatch: drop it, log at error level with only action and resource kind, and incrementbothy.authz.errorswithbothy.reason = filter_mismatch(07 7.4 rule 3).ListContextWindowis one statement withLIMITand anEXISTSforHasOlder, over the unique index(thread_id, thread_seq), filteringdeleted_at IS NULLand the excluded author in SQL, so the limit counts only selectable messages (ruling 8). -
Classification (
domain.Classify, ruling 3), first matching row wins:Message roletrustorigin = systemothersystemorigin = importotherunverifiedorigin = appotherunverifiedorigin = agent, author is the run's agentassistantselforigin = agent, other authorotherunverifiedorigin = human, author isruns.triggered_byusertriggerorigin = human, author inWithTriggerresultuserauthorizedorigin = human, otherwiseuserunverifiedOrigin decides before the author, so an imported message whose author is mapped to Alex stays
unverifiedand the system linethread.reopened(author is a person, 03messages) stayssystem. The triggering message is classified by the same table. Trust is a separate field of the response; no content, author or client field can set it (09 3.2:originis set only by the server). -
Session mode, floor and own messages (
domain.PlanContext, ruling 5). Facts: whether the run's agent operates T (fixedoperator_agent_idof a non-main thread, else the effective operator of C), the current session S of T (agents.Sessions), and whether a run of this agent withagent_session_id = Sandqueued_atbefore this run exists (PreviousSessionTrigger).guest: the agent does not operate T (09 3.6, R-AG-04): no session, floor zero, own messages included, channel context wanted.new: no current session, or S withstarted = false(09 9.1): floor zero, own messages included, channel context wanted.bind: S withstarted = trueand no earlier run of this agent in S: same asnew.resume: S withstarted = trueand an earlier run in S: own messages excluded (ExcludeAuthorID= agent), no channel context, floor =thread_seqof the triggering message of the latest earlier run in S that has a triggering message; if that message lies outside T (it is the parent message of an auto thread) or no such run exists, the floor is zero.- Use the function of M2-12 that computes
session.modeforagent.invocationif it exists, so delivery and context never disagree; otherwise put this one intointernal/runs/domainand let M2-12's code call it. Reason: 09 4.6 says a continued session knows what was delivered to it; a session that never started (started = false) or a guest knows nothing, and athread_seqof the main thread is no floor inside T.
-
Windows.
thread_context: if M lies in T,ContextWindow{ThreadID: T, AfterSeq: floor, BeforeSeq: thread_seq of M, Limit: history_backfill_limit, ExcludeAuthorID: per note 5}. If M is the parent message of T (auto thread, 09 3.4 point 5 and 3.7), T has no message before M:thread_contextis empty and the floor is zero.channel_context(ruling 6): only when the plan wants it, T hasparent_message_idP, and P lies in a thread of C (the thread's current channel). Window:ContextWindow{ThreadID: thread of P (the main thread of C), AfterSeq: 0, BeforeSeq: thread_seq of P, Limit: history_backfill_limit}; P itself is not included (09 4.6 "before the parent message"). When P lies in another channel (the thread was moved, 03threads.parent_message_id, M1-62),channel_contextis absent: content of the source channel must not reach people who read only the target (T-AI-01, SR9, 07 6). Forum posts have no parent and no channel context.- Both windows use the channel's
history_backfill_limitseparately; the value zero gives empty windows.
-
kernel/authorize.TriggerHolders(ruling 1, ruling 2):// TriggerHolders answers which people have effective level trigger on a target at decision // time. Implemented by authz; used by runs for the trust marker of the run context (09 4.6). type TriggerHolders interface { // WithTrigger returns, ascending and without duplicates, the ids among people that are // active humans with effective level trigger on the path of r. It writes no audit entry // and no check-log entry. An error never means "has trigger". WithTrigger(ctx context.Context, r Resource, people []ids.ID) ([]ids.ID, error) }Implementation in
authz:rmust be of kindchannel(else internal error). A person counts whenPrincipalSourcesays kinduserand statusactive(notpending,disabledordeleted), and the effective level on the channel path (07 4.2 step 4) istrigger: snapshot of that person without credentials (as forViewAs, cached the same way), grants of the person and of the groups in effect, the archive cap of 07 4.5 point 1 (nobody hastriggerin an archived channel or space), DM rule (participants havewrite). An active owner counts on every non-DM channel: trust asks whether the person may instruct the agent, and an owner triggers from any session (05 2.3 point 1);ActingForandViewAsplay no role here. Agents and apps never count. One call per request; it reads the rights version like every REST decision (07 7.3). If an earlier M2 ticket already added an equivalent query tokernel/authorize, use it (extend it to a batch if needed) and add no second one; name it in the report. -
Cutting and budget (
domain.CutContent,domain.FitBudget, rulings 8 and 9).- Per element of
thread_contextandchannel_context: ifcontent_mdis longer thanagent.context_message_max_bytesbytes, cut it at the longest valid UTF-8 prefix within the limit and settruncated: true.trigger_messageis never cut and never counted (plan row: "the triggering message is never truncated"). - Budget: only the bytes of
content_mdafter the per-element cut count. Order all candidates chronologically,channel_contextfirst (it lies before the thread), thenthread_context; walk from the newest backwards and keep elements while the running sum stays withinagent.context_max_bytes; the first element that does not fit and every older one are dropped. The kept elements are a contiguous newest suffix ("truncation from old to new", AG-21). truncated_before_seq(refers to T only): whenContextWindowreportedHasOlderor the budget dropped elements ofthread_context, set it to thethread_seqof the oldest keptthread_contextelement, or to thethread_seqof M when none is kept; otherwise absent. The adapter loads older messages withthread.read(09 4.6).- Arrays in the response are ascending by
thread_seq.
- Per element of
-
already_delivered: only in moderesume.DeliveredFollowupsOfSession(session S, message ids of the kept elements)returns themessage_idofrun_followupsrows indeliveredorabsorbedwhose run hasagent_session_id = S; those elements getalready_delivered: true. -
Response and API changes (rulings 10 and 11).
RunContext:run_id,context_floor_seq,trigger_message,thread_context(always present, possibly empty),channel_context(present, possibly empty, exactly when note 6 builds it; absent otherwise),truncated_before_seq(only when set).RunContextMessage: the fields of requirement 16;channel_idnew and required (the channel of the message, which is C for every element after note 6);truncatedandalready_deliveredonly when true;reply_to_message_idonly when set;attachmentsalways present. Attachments map from the messaging representation (Attachment.file):file_id,original_name,mime_type,size_bytes,scan_status;transcriptstays absent until M3-09.- openapi: add
channel_idtoRunContextMessage(required,Uuid, description "Channel of the message (SEC-AI-1)"), extend the descriptions named in Scope, update the example; regenerate the types and check them withmake codegen-check; keep thesummaryofgetRunContextunchanged, and if a generated block of chapter 06 still changes, regenerate it withmake api-tables(checked bymake api-checks). 09 4.6 gets the same field in its element list. - No JSON
null; absent fields are omitted (ADR 0053, encoder of M1-18).
-
Queries and the visible-channel predicate (07 7.4 rule 1). The queries of notes 1, 5 and 9 (
GetRunForContext,PreviousSessionTrigger,DeliveredFollowupsOfSession) read rows of exactly one run or one thread whose visibilityauthorize(run.context.read)checked first, which is the exception "loading a resource that has already been checked";ListContextWindowandGetContextMessageread one thread or one message of the checked run's thread, the parent's main thread, or the triggering message, and every item passesauthorize(message.read)in filter mode (note 3). Add each to the allowlist ofTestQueriesCarryVisibleChannelPredicateand, if present, to the exception list oftools/lint/sql_visibility.py(categoryafter_authorize) with this reason. Name the rule in the READMEs ofrunsandmessaging. This also settles the open point of 07 7.2 ("How agents read threads") for the run context: through the run, not throughScope, so an agent that operates a thread in a channel it cannot otherwise read still gets its context. -
Configuration. Register
agent.context_max_bytesandagent.context_message_max_bytes(integer, minimum one, defaults of 09 section 21, chapter09-agents.md) in the schema ofinternal/wireif absent; runmake config-referenceand keepmake config-doc-checkgreen.channel.history_backfill_limit_defaultandchannel.history_backfill_limit_maxbelong to M2-02. Code comments and docs name the keys, never their values. -
Logs and telemetry. The handler and the use case log no content, no handle, no display name and no message id list; errors carry the run id at most. Spans follow the request span of the chain; no new metric (signals.md names none). Extend the canary scenario of M1-69 with a context read whose thread holds canary content and an author with a canary display name: the values may appear only in the
200body to the agent (a reader with the right), never in a sink. -
Rulings for
docs/DECISIONS.md(one rowD-M2-13-<n>each, with date, reason and place):- The trust facts come from a new kernel port
authorize.TriggerHolders(WithTrigger), implemented byauthz;runsnever derives a level and never importsauthz(05 rule 1.1, 02 4.4). 02 4.5 names the port. - An active owner counts as
authorizedon every non-DM channel; people who are not active, agents and apps never count. - Classification order of note 4: origin before author.
- Only own runs in
queued,runningorwaiting_inputhave a context; any other state answers the same404as an unknown run. - Session mode, floor and own messages after note 5; the floor is non-zero only in mode
resumeand only when the previous trigger lies in T; channel context exactly in modesnew,bindandguest. channel_contextonly when the parent message lies in the thread's current channel; after a move it is absent; the parent message itself is not part of it.- Tombstones (deleted messages) are not part of
thread_contextorchannel_contextand count towards no limit; the triggering message appears as stored. - Byte accounting counts only
content_mdafter the per-element cut at a UTF-8 boundary; the budget keeps a contiguous newest suffix acrosschannel_contextthenthread_context; exclusions apply beforehistory_backfill_limit. truncated_before_seqrefers to T and is thethread_seqof the oldest kept element ofthread_context, or of M when none is kept.RunContextMessage.channel_id(required) carries the channel of each element (SEC-AI-1).- Optional fields appear only when they apply;
channel_contextis present (possibly empty) exactly when it is built. - A triggering message the agent cannot read ends the request with
404; a run withtrigger_kindother thanmessageis an internal error until M2-15 and M2-16 define its context. - A context read opens no unit of work and takes no lock; each request recomputes every part, including trust.
- The trust facts come from a new kernel port
Tests (write them first)¶
Unit:
internal/runs/domain:TestClassifywith one row per line of the table in note 4, plus an import authored by the triggering person (other,unverified), a system line authored by Alex (other,system), and the triggering message (user,trigger).internal/runs/domain:TestPlanContext: guest; no session; session withstarted = falseand an earlier failed run (new, floor zero);bind;resumewith the previous trigger in T (floor equals itsthread_seq, own messages excluded, no channel context);resumewhose previous trigger is the parent message (floor zero);resumewhose latest earlier run has no triggering message (the run before it decides).internal/runs/domain:TestCutContent: below, equal to and above the limit; a multi-byte character across the limit is not split; the result is valid UTF-8.internal/runs/domain:TestFitBudget: newest kept, oldest dropped; channel elements dropped before thread elements; the kept elements are contiguous; the sum never exceeds the limit; an element larger than the whole budget drops everything older too; the trigger is not an input.internal/runs/domain:TestTruncatedBeforeSeq:HasOlderonly, budget drop only, nothing kept, nothing missing.FuzzCutContentininternal/runs/domain: arbitrary bytes and limits never produce invalid UTF-8 or a result above the limit.internal/authz/app:TestTriggerHolders: granttriggeron the channel (in), on the space (in),writeonly (out), group granttrigger(in), expired grant (out), active owner without grant (in), disabled person (out), pending person (out), an agent id (out), archived channel (out), archived space (out), duplicates in the input (once), unknown id (out), source error (error, no result).internal/messaging/app:TestContextWindow: newestLimitkept, ascending order, tombstones and the excluded author not selected and not counted,HasOlderexact at both edges, full content abovemessage.list_content_max_bytes, filter-mode check with a denied item counted asfilter_mismatch.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain on 127.0.0.1:0, harness test/harness, people and agent tokens from the identity test helpers, fake clock; world of M2-09: space Support with channel Allgemein whose operator is the agent Lotse, space Marketing with the agent Texter; Robin is the owner, Alex has trigger on Support, Sam has trigger on Allgemein unless a test changes it, a person with the handle writer (address at example.com) has write only, Texter has a write grant on Allgemein and Allgemein allows mentioned agents where a test needs a guest; a run is triggered by Alex and delivered through M2-08 and pollAgentEvents; messages with origin agent, app and import are written through the real path where the base has one, otherwise through a test helper of messaging that writes its own table, as M1-57 does for tombstones):
TestRunContext_TrustMarkers_AG20: before Alex's trigger the thread holds messages of Sam (authorized), ofwriter(unverified), of Texter as guest (unverified, roleother), of an app (unverified), an import authored by Sam (unverified, roleother), athread.reopenedline (system), an earlier message of Lotse while the thread has no session (modenew;self, roleassistant) and a message of Robin, who as owner has no grant (authorized);trigger_messagehastrust = triggerand roleuser. Every element hasauthorwithid,kind,handleanddisplay_nameand no e-mail address (AG-20 server part for authors withouttrigger, other agents and imports).TestRunContext_SEC_AI_1: every element of all three parts carriesauthor,origin,kindandchannel_idequal to Allgemein,roleandtrust;reply_to_message_idis present on a reply;attachmentsis an empty array.TestRunContext_TrustAtRequestTime: Sam's grant is revoked between two runs (ADR 0054 "Confirmation"): Sam's message isauthorizedin the first context andunverifiedin the second; a second read of the same run after the revocation also showsunverified.TestRunContext_TriggerNeverTruncated: withagent.context_message_max_bytesandagent.context_max_bytesset below the size of Alex's message,trigger_message.content_mdis byte-equal to the stored content, has notruncated, and does not reduce the budget of the other parts.TestRunContext_SizeLimits_AG21: with small limits and a thread of messages whose sum exceedsagent.context_max_bytes, each long element is cut atagent.context_message_max_byteson a UTF-8 boundary withtruncated: true, the sum ofcontent_mdstays withinagent.context_max_bytes, the oldest elements are missing, the newest are present, andtruncated_before_seqequals the oldest keptthread_seq.TestRunContext_BackfillLimit: withhistory_backfill_limitbelow the number of earlier messages only the newest are present andtruncated_before_seqis set; with the value zerothread_contextis empty andtruncated_before_seqequals thethread_seqof M.TestRunContext_ResumeFloorAndAlreadyDelivered: first run started and ended with an answer of Lotse and anabsorbedfollow-up (M2-11), session started (M2-12); second run in moderesume:context_floor_seqequals thethread_seqof the first trigger, Lotse's answers are absent, the follow-up hasalready_delivered: true, and no message at or below the floor appears.TestRunContext_NewAndBindIncludeOwnMessages: afterresetAgentSession(modenew) and afterbindAgentSession(modebind) Lotse's earlier answers are present withself, the floor is zero andchannel_contextis built when the thread has a parent.TestRunContext_GuestRun: Texter mentioned as guest (allow_mentioned_agents): floor zero, own earlier messages present, no session needed,channel_contextbuilt for a thread with parent.TestRunContext_AutoThreadFirstRun: Alex mentions Lotse in the main thread withauto_thread; the run in the new thread hastrigger_message= M from the main thread,thread_contextempty,channel_context= the main-thread messages before M (newest withinhistory_backfill_limit), all withchannel_idAllgemein; a second run in that thread in moderesumehas floor zero and sees the new thread's messages.TestRunContext_ChannelContextAbsentAfterMove: a thread created from a main-thread message of Allgemein is moved (moveThread, M1-62) to another Support channel with the same operator; the first run's context has nochannel_context, and no content of Allgemein's main thread appears anywhere in the body.TestRunContext_TombstonesOmitted: a deleted earlier message is absent and does not count towardshistory_backfill_limit; a deleted triggering message appears with emptycontent_md.TestRunContext_QueuedRunReadable: the context is read while the run isqueued(beforestartRun) and whilewaiting_input.TestRunContext_NotFoundEquality: an unknown run id, a run in Marketing that Lotse cannot read, and Lotse's own run afterfinishRungive404with equal status, body (withoutcorrelation_idandinstance) and headers (withoutCorrelation-Id) (AZ-P4 status and body part).TestRunContext_ForeignAndHuman: Texter with read on Allgemein asking for Lotse's run gets403 forbiddenwithreasonnot_self; Alex with her session gets403 forbiddenwithreasonsubject_kind_not_allowed; each denial produces one aggregated deny entry in the audit log; a successful read produces no audit entry (R-AZ-04, 05 Auditno).TestRunContext_SchemaAndNoNull: every200body of this file validates againstRunContextofdocs/api/openapi.yamland contains no JSONnull; absent optional fields are omitted.TestRunContext_RouteMetadata: the generated route table namesrun.context.readandrl.agent_runtimeforgetRunContext, and the route is registered (AZ-P1).TestRunContext_NoContentInLogs: with the harness log writer, a context read over a thread with distinct content and display names leaves none of them in the captured log output.test/canary: the scenario includesgetRunContext; the canary test stays green (SEC-T10).
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/runs/... ./internal/messaging/... ./internal/chat/... ./internal/agents/... ./internal/authz/... ./internal/kernel/... ./internal/wire/... ./test/integration/runcontext/... ./test/canary/...make codegen-check,make arch,make api-lint,make api-checks,make register-check,make config-doc-check,make docsmake check
Acceptance criteria¶
-
getRunContextis enabled and registered withrun.context.readandrl.agent_runtime; AZ-P1 passes. - AG-20 (server part): authors without
trigger, other agents, apps and imports areunverified; the triggering person istrigger, other active people withtrigger(owners included) areauthorized, the own agent isself, system lines aresystem; roles follow 09 4.6. - Trust is computed per request: a person who lost
triggerisunverifiedin the next context. - AG-21: contents are cut at
agent.context_message_max_bytes, the sum stays withinagent.context_max_bytes, cutting goes from old to new, andtruncated_before_seqmarks the gap. - The triggering message is never cut and never counted.
- SEC-AI-1: every element carries author,
origin, kind andchannel_id;openapi.yamland 09 4.6 namechannel_id. - Floor, own messages,
already_deliveredandchannel_contextfollow the session mode; after a thread move no content of the source channel is in the context. - Only the own agent reads the context of its active run; unknown, invisible and ended runs give the same
404; denials are audited, reads are not. - No content, handle or display name in logs; the canary scenario includes the context read; no JSON
null. - Rulings recorded in
docs/DECISIONS.mdsection 10; 09 4.6 and 02 4.5 updated. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
runs,messaging,chatandauthz(andagentsif its port changed) updated (ports, the context use case,TriggerHolders, the query exceptions of note 11), package doc ofkernel/authorize. - Rulings of note 14 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason);docs/spec/09-agents.md4.6,docs/spec/02-architecture.md4.5 anddocs/api/openapi.yamlupdated in the same change. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.