Skip to content

M2-17: Command autocomplete

Status: ready | Depends on: M2-16 | Milestone: M2

Goal

A person who types the value of a slash-command option with autocomplete = true gets suggestions from the owner of the command, or an empty list, and never waits longer than command.autocomplete_timeout. autocompleteCommand checks the request like agent.invoke without a run: trigger on the channel, I2 (a personal subscription answers only its holder), SR2, SR4, read-only and archived channels. It then sends the ephemeral event command.autocomplete_requested to exactly one live gateway connection of the owning agent; in a forum channel without thread_id it asks nobody and answers an empty list at once after the check (R-AZ-26). The event carries a freshly issued invocation token with trigger_kind = autocomplete and no run_id. The owner answers with the gateway op command.autocomplete_result or with answerAutocomplete, with at most command.autocomplete_max_choices entries. The first valid answer goes back to the person. Without an answer in time, the person gets an empty list (AG-23). No run, no cost, no stored event and no model call ever comes from typing (09 "Autocomplete", R-AG-14). This puts the trigger path "autocomplete" of 05 rule 1.6 under the same check as every other trigger, which the red team of M2 tests for I2 ("another person triggering a personal subscription through every path ... autocomplete").

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: "user interface (M4)", "fencing across several nodes (M7)"; Exit criteria items 1 and 10; Test strategy; Red team: "another person triggering a personal subscription through every path (..., autocomplete)", "confused deputy through the server API", "forging or reusing the invocation token"; Risks: prompt injection); block "agents and extensions" with the rows M2-14, M2-16, M2-17, M2-18, M2-25, M2-29, M2-30, M2-31, M2-33, M2-34; section 13 row M7-17.
  • Dependencies: M2-16 (header). Implied through it and therefore not in the header: M2-08 (delivery, InvokeFacts.NewThread), M2-07 (agent consumer connections, live phase), M2-06 (token issuer, Verify, reference verifier), M2-05 (runs module), M2-04 (InvokeFacts), M2-02 (channel switches), M1-65 (hub, admission, authorize.Delivery(ctx)), M1-64 (gateway session, op dispatcher, gateway_ops, wstest), M1-56, M1-55 (chat.Threads.Info), M1-54, M1-53 (Channel.main_thread_id, Structure), M1-51 (agent records and policy), M1-44, M1-32 (signing keys), M1-28 (authorize, requirements, guards, deferred entries), M1-26 (kernel/textcheck), M1-25 (registry), M1-24 (audit), M1-20 (idempotency exemption), M1-19 (rate limits), M1-18 (chain, validation codes, proof), M1-17 (api/operations.yaml, route generator), M1-13, M1-08 and M1-07 (configuration keys and cross-checks). Also in the closure and required: M1-66 (kernel/eventlog.EphemeralEvent, EphemeralPublisher, the ephemeral rules of the hub); it is merged before this brief starts. If the base branch lacks it, stop with Verdict: BLOCKED M1-66 not merged. Used only if merged: M2-03 (project readiness for the project_not_ready case).
  • docs/spec/09-agents.md: 1 "Principles" (points 3 and 4), 2.4 "Tool class", 3.1 "Operator", 3.6 "Guest agents", 4.2 "Transports", 4.3 "Connection and cursor", 4.5 "Invocation token" (claims, "Check in the adapter" point 6), 10 "Slash commands" with 10.1 "Built-in commands", 10.2 "Registration", 10.3 "Visibility and name conflicts", 10.4 "Execution", 10.5 "Autocomplete"; 16.2 "Required scenarios" (AG-23); 19 "Decisions of this chapter" (R-AG-13, R-AG-14); 20 "Alignment with the core contract" (E-AG-11, E-AG-17); 21 "Configuration values of this chapter" (command.autocomplete_timeout, command.autocomplete_max_choices).
  • docs/spec/04-sync-protocol.md (core contract, read only): 2.1 "Two kinds" (command.autocomplete_requested is ephemeral), 2.2 "Envelope" (invocation_token, trace_context, correlation_id; no seq and no permission_version for ephemeral events; payload rules 1 to 3), 6.2 "Ops" (row command.autocomplete_result; a failing op answers error and keeps the connection), 6.4 "Close codes" (4002), 9 "Delivery guarantee" (point 4: at most once), 12 "Typing and agent presence" (point 3), 15.1 "Connection and cursor" (point 5), 15.6 "Webhook as transport" (no ephemeral events there), 15.7 "Autocomplete" (point 2: thread_id of payload and token and the forum case, R-AZ-26), 17 (R-SY-07, R-SY-16, R-SY-17, R-SY-25), 18 "Configuration values of this chapter".
  • docs/spec/05-actions.md (core contract, read only): 1 "Binding rules" (rules 1, 2, 4, 6 with "autocomplete", 8, 9); 2.1 Column "Permission" (agent-self, app-self); 2.3 "Effective level"; 2.4 (Audit no, every denial of a request is audited, R-AZ-04); 2.5 "Decision"; 2.6 "Scopes for apps" (command.autocomplete.respond needs commands:write); 2.7 "Effect (effect)" (both actions are read); 3 "Rate-limit classes" (rl.trigger, rl.agent_runtime); 4.10 rows agent.invoke, agent.events.consume, command.read, command.autocomplete, command.autocomplete.respond, command.invoke; 5 "Agent invocation agent.invoke" (points 1 to 8, the claim table: run_id absent for trigger_kind = autocomplete, and for command.invoked as R-AZ-25 says); 6 "Durable event types and receive action" (table "Ephemeral events", row command.autocomplete_requested); 7 "WebSocket ops and action" (row command.autocomplete_result); 8 (R-AZ-01, R-AZ-04, R-AZ-12, R-AZ-18, R-AZ-19, R-AZ-26).
  • docs/spec/03-data-model.md (core contract, read only): commands (owner, options with command_options.v1 and autocomplete, scope columns, required_level, removed_at); event_log (last paragraph: ephemeral events and requests for autocomplete are never stored); threads (kind = 'main', R-DM-11: text channels have one main thread, forums none); 6.6 "Notifying the gateway processes".
  • docs/spec/00-glossary.md: "Trigger kind", "Ephemeral event", "Slash command".
  • docs/spec/02-architecture.md: 4.1 "Rules" (point 4, source ports), 4.3 "Ports, events and sources per module" (runs: CommandExec "including autocompletion"; gateway implements kernel eventlog.EphemeralPublisher), 4.4 "Allowed dependencies", 4.6 "Actions per module" (bullet "command.invoke and command.autocomplete belong to runs"), 4.7 "Event types and WebSocket ops per module" (row runs), 7.3 "Other checks in the gate", 9.3 "Proof of the check", 11.6 "Ephemeral events" (last sentence), 14.2 "Deadlines", 15.3 "Shutdown".
  • docs/spec/06-api.md: 4 "Authorization in the API" (point 6: commands without trigger right answer 403 agent_invocation_denied with reason), 5 "Errors" (points 3 and 7; codes validation_failed, not_found, gone, agent_invocation_denied, channel_read_only, rate_limited), 7 "Idempotency" (point 3: autocompleteCommand is read_only, answerAutocomplete is agent_runtime), 9 "Rate limits", 11 "Real-time gateway" (point 6), 15 "Contract tests and gate" (G8, G9), 16 "Endpoints" (groups "Commands" and "Agent runtime"), 17 "WebSocket ops and events", 19 (R-API-03, R-API-27, R-API-28).
  • docs/spec/07-permissions.md: 4.1 "Decisions and reasons" (agent_not_reachable, billing_mode_personal_not_holder, tool_class_owner_only, project_not_ready, origin_not_human, level_insufficient, channel_read_only, channel_archived, not_self, subject_kind_not_allowed, scope_missing), 4.2 "Flow" (the paragraph on deferred entries, the audit paragraph and filter mode), 4.5 "Archived spaces and channels", 5 "SR1 Backend policy (I1, I2)", "SR2 Tool class", "SR4 Only humans trigger", "SR9 Acting-for binding (confused deputy)", 10 "Examples" (the test world), 11.2 "Required tests" (AZ-P1, AZ-P2, AZ-P4, AZ-P13).
  • docs/spec/10-backend-policy.md: 3 "Invariants" (I2), 6 "Dispatch check (I2)" ("Follow-ups, interactions, commands and autocompletion are triggers and go through the same check").
  • docs/spec/11-extensions.md: 9 "Commands and interactions for apps" (out of scope here, M2-25), 11.2 "Hand-written layer" (row commands: "autocomplete with a time guard", M2-29 and M2-30).
  • docs/spec/01-product.md: 3.2 "Agents" (row "Slash commands with suggestions and options"), 5.1 "Desktop (macOS, Windows)" (D5 "Slash autocomplete").
  • docs/spec/19-security.md: SEC-IN-1, SEC-IN-4, SEC-LOG-2, SEC-LOG-4, SEC-LOG-6, SEC-AI-7.
  • docs/api/openapi.yaml: operations autocompleteCommand, answerAutocomplete, invokeCommand, listChannelCommands, getCommand, registerCommand, getJwks; parameters ChannelIdPath, CommandIdPath, RequestIdPath; schemas AutocompleteQuery, AutocompleteResult, AutocompleteAnswer, CommandChoice, CommandOption, CommandOptionValue, Command, CommandAutocompleteRequestedPayload, CommandAutocompleteRequestedEvent, GatewayAutocompleteResult (maxItems of choices), GatewayError, EventEnvelope, Channel (main_thread_id), Problem (x-problem-codes), FieldError; responses BadRequest, Forbidden, NotFound, Gone, TooManyRequests.
  • docs/api/asyncapi.yaml: operation receive.command.autocomplete_result; messages op.command.autocomplete_result and event.command.autocomplete_requested (x-event-kind: ephemeral, x-receive-action: agent.events.consume).
  • docs/spec/actions.yaml (M1-02): entries command.autocomplete, command.autocomplete.respond, event command.autocomplete_requested (receivers: agent_or_app), op command.autocomplete_result.
  • ADRs 0013, 0017, 0022, 0023, 0049, 0050, 0052, 0053, 0065.
  • docs/DECISIONS.md: D-AG-13, D-K-B1, D-K-B8, D-K-D11, D-W-100, D-W-103; section 10 for the format of new rows.
  • docs/security/threat-model.md: 5.10 "Agent adapters and runtime"; register rows T-AI-02, T-AI-07, T-AI-08, T-TK-05. docs/RISKS.md: RK-09, RK-37. docs/OPEN-QUESTIONS.md: OQ-01 (default: strict I1 without owner exception), OQ-07 (default: multi-node operation in M7, "The code stays node-neutral from M1 on").
  • docs/observability/signals.md: no signal names autocomplete; this ticket adds no metric.
  • docs/operations/configuration.md: command.autocomplete_timeout, command.autocomplete_max_choices, http.request_timeout, sync.event_payload_max_bytes, sync.ws_max_frame_bytes, sync.outbound_queue_max, agent.invocation_token_ttl, ratelimit.rl.trigger.per_minute, ratelimit.rl.trigger.burst, ratelimit.rl.agent_runtime.per_minute, ratelimit.rl.agent_runtime.burst, authz.not_found_timing_tolerance, audit.deny_aggregate_window, cluster.mode.
  • M1 briefs: M1-17 (enable list api/operations.yaml, route generator), M1-18 (chain, FieldError codes unknown_member, too_many_items, too_short, too_long, the proof of 02 9.3), M1-19 (rl.* classes), M1-20 (exempt operations), M1-24 (denials aggregated by the recorder), M1-25 (authorize.Registry, Event, op ownership), M1-26 (kernel/textcheck.DisplayText and its codes invalid_utf8, control_character, bidi_control), M1-28 (Design notes "Public contract" with Resource and InvokeFacts, "Requirement table", "Deferred entries", "Guards" for agent.invoke, decision to error mapping, WithPermissionVersion, instance.Versions, TestDeferredActionsGolden), M1-51 (agents port, AgentPolicySource), M1-53 (Structure, Channel.main_thread_id, forums without main thread), M1-55 (Threads.Info), M1-64 (dispatcher Register(op, action, handler), decode rule 4002, gateway_ops, wstest), M1-65 (hub, admission with the receive action, authorize.Delivery(ctx), two server instances in one test binary), M1-66 (EphemeralEvent, PublishEphemeral, "never before catchup.complete", drop when the queue is full, decorator for M7-17), M1-07 and M1-08 (config.Key with Min and Max, AddCheck, make config-reference).
  • M2 briefs: M2-04 (InvokeFacts{AgentID, TriggerOrigin, MentionsAgent, ActiveRunAgentID}), M2-06 (Issue(ctx, InvocationGrant), the claim table with the row autocomplete, Verify, runstest.NewReferenceVerifier, ReferenceExpectation, the redacting InvocationToken; its issuer refuses a zero thread_id, and R-AZ-26 keeps that: no autocomplete token is ever issued without a thread), M2-07 (consumer connections, the live phase after catchup.complete, the op handler pattern for ack), M2-08 (InvokeFacts.NewThread, "the claims of a delivery", the world of its tests), M2-10 (pattern only: the op handler run.update in gateway/app), M2-16 (the commands table, agents.Commands, runs.CommandExec, the visibility of 09 10.3, the problem mapping of invokeCommand).
  • Code (merged names win, D-W-100): services/server/architecture/modules.yaml (module runs: actions command.autocomplete, command.autocomplete.respond, ephemeral_events: [run.updated, command.autocomplete_requested], ws_ops: [run.update, command.autocomplete_result], depends_on: [instance, chat, agents, messaging, billing]), services/server/internal/wire/configkeys_commands.go (M2-16), configchecks.go and configchapters.go (configuration keys and cross-checks), internal/runs, internal/agents, internal/chat, internal/gateway, internal/authz, internal/kernel/authorize, internal/kernel/eventlog, internal/kernel/textcheck, internal/kernel/clock, internal/platform/pg/pgtest, internal/identity/adapters/identitytest, internal/runs/adapters/runstest, internal/wire, services/server/api/operations.yaml.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. 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).
  4. 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.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. 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.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/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:

  1. autocompleteCommand uses the action command.autocomplete: resource channel, permission "trigger and agent.invoke without a run (for app commands required_level)", Audit no, Permissions no, class rl.trigger, effect read, no Idempotency-Key (x-idempotency-exempt: read_only) (05 4.10, 2.7; 06 7; openapi).
  2. The request is checked like agent.invoke without a run, I2 included: the person is an active human (SR4), has trigger on the channel, the channel is not a DM, not read-only and not archived, the owning agent is active and reachable (operator, or guest under allow_mentioned_agents), personal agents answer only their credential holder (I2), tool class host only for owners (SR2), and a project-bound forum must be ready. Point 8 (active run) does not apply (04 15.7 point 1; 09 10.5 point 2; 05 5 and rule 1.6; 10 6).
  3. No read right gives 404 not_found, identical to an unknown ID; a denial by the trigger right or by agent.invoke gives 403 agent_invocation_denied with reason; a read-only or archived channel gives 403 channel_read_only; every denial of the request is audited and aggregated per audit.deny_aggregate_window (05 rule 2, 1.8, 2.4; 06 4 point 6; 07 4.1, 4.2, 4.5; R-AZ-01, R-AZ-04; 10 6).
  4. The server sends the ephemeral event command.autocomplete_requested {request_id, command_id, channel_id, thread_id, user_id, option, value, options, expires_at} to one active gateway connection of the command's owner. For an agent owner it carries a freshly issued invocation token (trigger_kind = autocomplete, without run_id, triggering object command_id) as the delivery field invocation_token. Payload and token carry as thread_id the thread of the request or, in a text channel without one, the main thread; in a forum channel without thread_id nothing is sent to an agent owner and no token is issued, and after the check the person gets an empty list at once (04 15.7 point 2; 05 5 row thread_id; R-AZ-26). The event has no seq, is delivered at most once and is never stored (04 2.1, 2.2, 9 point 4, 15.1 point 5, 15.7 point 2; 05 5 claims, 6 table "Ephemeral events"; 09 4.5, 10.5; 03 event_log; R-SY-07, R-SY-17).
  5. The owner answers with the op command.autocomplete_result {request_id, choices} or with answerAutocomplete. Both use the action command.autocomplete.respond (resource command, agent-self or app-self as owner of the command, class rl.agent_runtime, effect read, app scope commands:write, exempt agent_runtime), with at most command.autocomplete_max_choices entries (04 6.2, 15.7 point 3; 05 2.6, 4.10, 7; 06 7; asyncapi receive.command.autocomplete_result).
  6. Without an answer within command.autocomplete_timeout the server answers 200 with an empty list (AG-23; 04 15.7 point 4; 09 10.5 point 4; R-AG-14).
  7. Autocomplete never calls a model and never costs anything: no run, no runs row, no durable event, no cost entry. The token cannot start a run, because it has no run_id (09 10.5 point 5; R-AG-14; D-AG-13; 05 5).
  8. No JSON null in bodies, frames or the event; every response, frame and payload matches its schema (ADR 0053; R-API-03; 06 15 G8 and G9).
  9. Partial texts, option values, choices and tokens never appear in logs, audit entries, the event log or telemetry attributes (SEC-LOG-2, SEC-LOG-4, SEC-LOG-6; 03 event_log).

Scope

All paths under services/server/ unless they start with docs/. No migration: commands comes from M2-16, and an autocomplete request is never stored (03 event_log). No new top-level Makefile target.

Create:

  • internal/runs/autocomplete.go (port, doc comment on every symbol): the DTOs AutocompleteQuery, AutocompleteResult, AutocompleteAnswer, Choice, OptionValue; the two methods that CommandExec gains (Design note 1); the source port AutocompleteRelay (Design note 1).
  • internal/runs/domain/autocomplete.go: pure rules. Target resolution (Design note 3), query validation and answer validation (Design note 9), the payload builder for CommandAutocompleteRequestedPayload, the InvocationGrant builder for trigger_kind = autocomplete (Design note 4), the request state machine open -> answered | expired with the tombstone rule (Design note 6).
  • internal/runs/app/autocomplete.go: the use cases Autocomplete and AnswerAutocomplete and the in-process registry of open requests (Design notes 2, 6 and 7).
  • internal/runs/adapters/httpapi/autocomplete.go: the strict-server methods for autocompleteCommand and answerAutocomplete.
  • internal/gateway/domain/autocompletetarget.go: the pure selection of one connection (Design note 5).
  • internal/gateway/app/autocomplete.go: the relay on top of the hub of M1-65 and M1-66 (Design note 5) and the handler of op command.autocomplete_result (Design note 8).
  • internal/gateway/adapters/autocompleterelay/: implements runs.AutocompleteRelay on the gateway app, as consumerfeed implements agents.ConsumerFeed (M2-07).

Change:

  • internal/kernel/authorize: if M2-16 did not add command facts to Resource, add Command *CommandFacts with CommandFacts{OwnerID ids.ID; OwnerKind string; RequiredLevel string} and a doc comment (Design note 3).
  • internal/authz: replace the deferred entry of command.autocomplete with the requirement of Design note 3; remove it from TestDeferredActionsGolden; tests. command.autocomplete.respond keeps the M1-28 rule Any(AgentSelf, AppSelf).
  • internal/runs registration (Register(r *authorize.Registry) of the port): the ephemeral event type command.autocomplete_requested for module runs (M1-25), if M2-05 or M2-16 did not register it.
  • internal/agents (port of M2-16), only where the merged code lacks it: a read of one command without authorize for callers that authorize themselves (Commands.Info(ctx, id) or the merged name, like chat.Threads.Info), returning owner, options, scope and removed_at; the rule "the command's scope covers this channel" if M2-16 did not export it.
  • internal/wire: bind autocompleterelay into runs; register the op command.autocomplete_result with action command.autocomplete.respond in the dispatcher of M1-64; embed the two HTTP handlers; register command.autocomplete_timeout, add the Max to command.autocomplete_max_choices and add the cross-check; update the golden files of registrations.
  • internal/wire/configkeys_commands.go (created by M2-16): register command.autocomplete_timeout (duration, positive, chapter 09-agents.md) there. Give the existing registration of command.autocomplete_max_choices the Max of Design note 10 and do not register it a second time (NewSchema rejects duplicate names, M1-07). Add the cross-check autocomplete_below_request_timeout to configChecks in configchecks.go. Add the constant chapter09 to configchapters.go if no earlier ticket added it.
  • api/operations.yaml: move autocompleteCommand and answerAutocomplete from later to enabled (sorted) and command.autocomplete_result from gateway_ops.later to gateway_ops.enabled; rerun the route generator (make codegen-check shows no diff afterwards).
  • docs/api/openapi.yaml (spec first): maxItems on AutocompleteAnswer.choices and AutocompleteResult.choices, equal to the one on GatewayAutocompleteResult.choices, each with the description "At most command.autocomplete_max_choices."; descriptions of autocompleteCommand (empty list at once without a live connection of the owner, and for an agent-owned command in a forum channel without thread_id after the check, R-AZ-26; 403 agent_invocation_denied reasons; 404 for an unknown or unusable command) and of answerAutocomplete (404 unknown request, 410 gone after the wait ended, 204 without effect for a later answer, 400 rules of Design note 9). Then make api-lint, make api-tables, make api-checks.
  • docs/spec/09-agents.md 10.5 "Autocomplete" (not core contract; English like the chapter): the API-visible rulings of Design notes 2, 3, 5, 6, 7 and 9, and the reference to apps corrected from "section 3" to "section 9" of 11 (04 15.7 point 2 names section 9, where interactions_url is used).
  • docs/spec/02-architecture.md 4.3: row runs "declares AutocompleteRelay", row gateway "Implements sources: runs.AutocompleteRelay".
  • docs/operations/configuration.md: regenerate section 4 with make config-reference if the keys were not registered before; make config-doc-check green.
  • docs/security/threat-model.md 5.10: one row "I: An agent's suggestions reveal content that the triggering person may not read" with the mitigation "SR9 applies only in a run; residual risk until M2-14" and register T-AI-02 (Design note 12).
  • READMEs of runs, gateway and authz (English, like the existing files); services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M2-17-1 and following (Design note 14).

Out of scope

  • Registering, changing, removing, listing and running commands, the commands table, reserved names, control and model commands, command.changed, command.invoked and its token: M2-16.
  • Autocomplete for app-owned commands: the app's gateway connection with events:subscribe, the synchronous POST to interactions_url within app.interaction_timeout, the admission rule for apps (the registered receive action is agent.events.consume) and the end-to-end test with an installed app: M2-25, which depends on this ticket (D-W-103). Here an app-owned command gets an empty list at once (Design note 2), and the requirement of Design note 3 already decides app owners.
  • Distribution of the request and of the answer across api nodes (cluster.mode = multi, NOTIFY ephemeral, 02 11.6): M7-17 (no plan row names it yet; reported as a plan gap and a spec gap).
  • SR9 for API calls an adapter makes while it computes suggestions (an autocomplete token as acting_for): M2-14; until then the residual of Design note 12 stands.
  • The adapter side of R-AG-14 ("a handler never calls a model"), the SDK module commands with its "time guard", and AZ-P6 for the autocomplete token: M2-29, M2-30, M2-33, M2-34. The data-driven run of AG-23 in the conformance suite with the fake agent: M2-31.
  • The composer in the clients (D5, static choices after an empty answer, ui.autocomplete_debounce): M4 and M5.
  • Built-in commands (/stop, /status, /new, /resume, /title, /close, 09 10.1): they have no commands row and no command_id, so autocompleteCommand cannot address them (M2-16; sessions for /resume: M2-12).
  • Webhook transport and REST poll for agents carry no ephemeral events (04 15.6, 7.4); nothing is built for them.
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-17-command-autocomplete; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(runs): ..., feat(gateway): ..., feat(authz): ...) 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 of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (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. Never log, print or persist an invocation token, a partial text, an option value or a choice on the server side, not even in test output; tests compare them in memory. No network calls except package downloads for pinned tools and Go modules.
  • No agent runtime, adapter, SDK, model or third-party service in any test: the agent side is the wstest client of M1-64 with the agent's API token and a consumer name (M2-07), or plain HTTP with that token and DPoP proofs from identitytest.
  • Time-dependent tests set short values for command.autocomplete_timeout through the configuration loader or drive the wait with the kernel/clock test clock; no test depends on a default value.
  • Never lower a threshold, disable a gate or delete a test.
  • 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>.
  • The same holds for the merged code of M2-04 to M2-08 and M2-16: follow its names; where it lacks a function this brief expects from it (for example a command read without authorize), add it in the module that owns it and report it.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11). The one gap this brief found (a token needs thread_id, but a forum without a post has no thread) is closed in the core contract by R-AZ-26 (D-W-104; Design note 0).

Design notes

  1. Autocomplete without a thread (core contract, amended by R-AZ-26 under D-W-104; no check any more): 04 15.7 point 2 and 05 section 5, claim table row thread_id, say that for an agent-owned command payload and token carry the thread of the request as thread_id, or the main thread in a text channel without thread_id. In a forum channel without thread_id the check of 04 15.7 point 1 runs as for a new thread of the channel; if it allows, the server does not ask the agent, issues no token and answers an empty list at once. 09 4.5 and 10.5 mirror this. Notes 2 g, 3 and 4 and the tests TestAutocomplete_AppOwnerAndForumWithoutThread and TestAutocomplete_ForumWithoutThread build exactly this.

  2. Placement and ports (02 4.3, 4.4, 4.6, 4.7, 7.3; 02 4.1 point 4). runs owns both actions, the ephemeral event and the op (modules.yaml). The port runs.CommandExec of M2-16 gains (follow the merged name of the port):

    // Autocomplete asks the owner of a command for suggestions for one option and waits at most
    // command.autocomplete_timeout (09 10.5). It never starts a run.
    Autocomplete(ctx context.Context, q AutocompleteQuery) (AutocompleteResult, error)
    // AnswerAutocomplete accepts the owner's suggestions for an open request (REST and gateway op).
    AnswerAutocomplete(ctx context.Context, a AutocompleteAnswer) error
    
    type AutocompleteQuery struct {
        ChannelID, CommandID ids.ID
        ThreadID             ids.ID // zero when the request names no thread
        Option, Value        string
        Options              []OptionValue // the other options entered so far
    }
    type OptionValue struct{ Name, Value string }
    type Choice struct{ Name, Value string }
    type AutocompleteResult struct{ Choices []Choice } // never nil: an empty list encodes as []
    type AutocompleteAnswer struct {
        RequestID ids.ID
        Choices   []Choice
    }
    

    runs declares the source port and gateway implements it (02 4.1 point 4; gateway may use runs, not the other way round):

    // AutocompleteRelay hands one autocomplete request to exactly one live gateway connection of
    // the command's owner in this process (04 15.7 point 2). It never blocks on a connection.
    // sent is false when no such connection exists or its queue dropped the frame.
    type AutocompleteRelay interface {
        Ask(ctx context.Context, ownerID ids.ID, ev eventlog.EphemeralEvent, invocationToken string) (sent bool, err error)
    }
    

    invocationToken is the revealed string of M2-06's InvocationToken, as in M2-08's DeliveryOutcome; empty for app owners. Ruling for docs/DECISIONS.md: a source port of runs, not a new kernel port, because the one-connection rule, the report back and the delivery field belong to this use case only; precedent agents.ConsumerFeed (M2-07). The op handler lives in gateway/app and calls CommandExec.AnswerAutocomplete, as ack (M2-07) and run.update (M2-10) do; authorize runs inside runs.

  3. Use case Autocomplete, in this order. It holds no transaction and no lock, writes nothing and appends no durable event: a. Load the command through the agents port (M2-16). Unknown, or removed_at set: 404 not_found. b. Thread: if ThreadID is set, read chat.Threads.Info; unknown, or its channel is not ChannelID: 404 not_found. This check runs before authorize, because M1-28 treats contradictory path hints as an internal error. c. Channel facts through the chat port (kind, space, main_thread_id); unknown: 404 not_found. d. The command's scope (scope_kind with scope_space_id or scope_channel_id, 03 commands) must cover the channel; otherwise 404 not_found. Ruling for docs/DECISIONS.md: outside its scope a command does not exist at this place (09 10.2 "Scope"). Whether the owner may be reached here is decided by authorize (agent_not_reachable), not by runs. e. Target thread (note 3) and authorize (note 3), with the rights version read once through instance.Versions and pinned with authorize.WithPermissionVersion, in request mode (not filter mode), so the authz layer audits every denial (R-AZ-04, AZ-P13). The answers of a, b, c and d and of an invisible channel have the same status, the same headers and the same problem body apart from correlation_id and instance (AZ-P4). f. Query validation (note 9): 400 validation_failed. g. Answer 200 with an empty list at once, without contacting anybody and without issuing a token, when the owner is an app (until M2-25), or when the owner is an agent and there is no target thread (a forum channel without thread_id; R-AZ-26, note 0). h. Build the payload (note 4); if the encoded payload exceeds sync.event_payload_max_bytes: 400 validation_failed with errors[].code too_long, an empty pointer (a rule of the whole request) and detail naming the key. Ruling for docs/DECISIONS.md: truncation (04 2.2 rule 2) makes no sense for a partial text, and PublishEphemeral would only report a programming fault. i. Issue the token (note 4), register the open request (note 6), then call AutocompleteRelay.Ask. sent = false: remove the request and answer 200 with an empty list at once. Ruling for docs/DECISIONS.md: without a live connection of the owner the server does not wait (R-AG-14 "never hangs"; adapters on REST poll or webhook never receive ephemeral events, 04 15.6 and 7.4). j. Wait (note 6): the first accepted answer gives 200 with its choices; the end of command.autocomplete_timeout gives 200 with an empty list; the end of the request context (client gone, http.request_timeout, shutdown) marks the request expired and writes nothing.

  4. Rights and target.

    • Target thread (R-AZ-26): the given thread; else, in a text channel, the channel's main thread (main_thread_id, R-DM-11), which then also goes into payload and token as thread_id; else (forum) none, and note 2 g answers. So the adapter's check of 09 4.5 point 6 compares equal thread_id values in payload and token.
    • Descriptor: Resource{Kind: channel, ID: ChannelID, ThreadID: <target thread or zero>, Command: &CommandFacts{OwnerID, OwnerKind, RequiredLevel}, Invoke: &InvokeFacts{AgentID: owner, TriggerOrigin: "human", MentionsAgent: true, ActiveRunAgentID: zero, NewThread: <true when there is no target thread>}}. OwnerKind comes from the agents port: an agent record exists for agent owners (M1-51), every other owner is an app.
    • Requirement of command.autocomplete in authz (replaces Deferred, 07 4.2): owner kind agent: Level(trigger) and the agent.invoke guard of M1-28 steps 1 to 6 on the target thread (on the channel with the main-thread rule of M2-08's NewThread when there is none); step 7 never applies because ActiveRunAgentID is zero ("without a run", 05 4.10). Owner kind app: Level(<RequiredLevel>) only, without agent.invoke and I2 (R-AZ-18). A descriptor without Command, or an agent owner without Invoke, is an internal error. The read-only and archive rules of M1-28 run as for every trigger action (05 rule 1.8; 07 4.5).
    • MentionsAgent = true: a slash command addresses its owner explicitly, like a mention, so the mention rule of 05 5 point 3 holds and a guest owner still needs allow_mentioned_agents, write and organization (09 10.3; R-AZ-19). Ruling for docs/DECISIONS.md. If M2-16 decided otherwise for command.invoke, follow M2-16 and report it.
    • Problem mapping of command.autocomplete (06 4 point 6): not_found gives 404 not_found; forbidden with channel_read_only or channel_archived gives 403 channel_read_only with reason; device_pending and scope_missing keep their codes (M1-28 mapping); every other forbidden reason (level_insufficient, origin_not_human, agent_not_reachable, billing_mode_personal_not_holder, tool_class_owner_only, project_not_ready, dev_environment_not_ready) gives 403 agent_invocation_denied with reason. Reuse the helper with which M2-16 maps invokeCommand; if it has none, add one in runs and use it for both.
    • command.autocomplete.respond: Resource{Kind: command, ID: CommandID, ChannelID, ThreadID: <target thread>, OwnerID: owner}; M1-28 decides Any(AgentSelf, AppSelf) with visibility on the path (the operator rule of 07 2.9 makes the channel or the fixed thread visible to its operator); apps need commands:write (05 2.6).
  5. Token and event.

    • Grant for M2-06's Issue: AgentID owner, TriggeredBy the person, TriggerKind autocomplete, no RunID, CommandID, ThreadID the target thread, ChannelID, SpaceID the channel's space, Level trigger (an allow implies effective trigger, as in M2-08), BillingMode and ToolClass the current target of the agent through the agents port (never the reported class), PermissionVersion the pinned version of note 2 e. A fresh token per request (R-SY-07). The token lives only in memory and in the frame (SEC-LOG-2).
    • Event eventlog.EphemeralEvent: Type command.autocomplete_requested, SchemaVersion from the register, Actor {id: person, kind: user}, Scope {space_id, channel_id, thread_id}, Resource {kind: command, id: command_id} without version, Audience the owner. The envelope has a new UUIDv7 id, occurred_at, the request's correlation_id, trace_context when the request is recorded (04 2.2), the delivery field invocation_token, and no seq and no permission_version.
    • Payload CommandAutocompleteRequestedPayload: request_id (new UUIDv7), command_id, channel_id, thread_id (the target thread), user_id (the person), option, value, options (copied; absent when the query had none, never null), expires_at = now plus command.autocomplete_timeout (RFC 3339 UTC with milliseconds). No message content, no prompt (04 2.2 rule 1).
  6. Relay (gateway). Candidates: connections of ownerID in this process that are in the live phase. For agents that means after the consumer window sent catchup.complete (M2-07 note 5), and ephemeral frames never go out before it (M1-66). Choose exactly one: the connection of consumer default if it is live, otherwise the most recently attached live connection. Ruling for docs/DECISIONS.md: default is the adapter's main stream (04 6.3); otherwise the newest connection wins, as with consumers (R-SY-09). Admit it through the hub's admission with the registered receive action agent.events.consume and audience self, in authorize.Delivery(ctx) mode (filter, no audit), as M1-65 and M1-66 admit personal events. Enqueue without blocking; if the frame would exceed sync.outbound_queue_max it is dropped (never 4014, M1-66) and Ask returns false. Person connections and other principals never get the frame. Ask returns an error only for programming faults (unregistered type, oversized payload); runs turns that into 500 internal_error.

  7. Open requests (runs/app, in process memory; a mutex-guarded map keyed by request_id):

    • Entry: owner, command, channel, target thread, expires_at, state open, answered or expired, and the accepted choices.
    • Register before Ask, so an answer that arrives at once finds the entry.
    • The wait selects on the answer, a timer from kernel/clock set to command.autocomplete_timeout, and ctx.Done(). Every transition is a compare-and-swap under the mutex: open -> answered by the first valid answer, open -> expired by the timer or the end of the context. A timer that fires after an answer won returns the answer's choices; exactly one outcome per request.
    • Tombstones: answered and expired entries stay until expires_at plus command.autocomplete_timeout, then they are forgotten. Every Autocomplete and AnswerAutocomplete call sweeps them; there is no background goroutine. Ruling for docs/DECISIONS.md: 410 gone needs a memory of the request (06 5 "autocomplete"), and the existing key bounds that memory without a new number; rl.trigger bounds the number of entries per person.
    • A server restart forgets every request: requests are ephemeral and at most once (04 9 point 4). With cluster.mode = multi before M7-17, only owner connections and REST answers on the same api node are reached; elsewhere the person gets an empty list and the answer gets 404. Name this limit in the runs README (OQ-07; RK-37).
  8. Use case AnswerAutocomplete, in this order: a. Find the entry. Unknown, never issued by this process, or forgotten: 404 not_found, without authorize (an unknown resource ID, as M1-62 note 1 a; answers of 400 and above pass the proof of 02 9.3). b. authorize(actor, command.autocomplete.respond, <descriptor of note 3>) in request mode: 404 not_found; 403 forbidden with reason not_self (another agent that can read the place) or subject_kind_not_allowed (a person); 403 scope_missing for an app. c. Answer validation (note 9): 400 validation_failed; the request stays open, so a corrected answer within the window still counts. d. State expired: 410 gone. State answered: 204 without effect (R-API-27). Ruling for docs/DECISIONS.md: the first answer counts (06 7 point 3, agent_runtime), and a retry after a lost response must not look like an error; a late answer learns that it came too late. e. Compare-and-swap open -> answered with the choices, wake the waiter, answer 204. Two answers at the same moment, over REST and over the op, give exactly one accepted answer. No audit entry for an allowed answer (Audit no); denials are audited by authz.

  9. Gateway op command.autocomplete_result (04 6.2; asyncapi receive.command.autocomplete_result). Register it in the dispatcher of M1-64 with action command.autocomplete.respond and move it to gateway_ops.enabled. Rate limit rl.agent_runtime keyed by the principal before anything else. A frame that breaks GatewayAutocompleteResult follows M1-64's decode rule (4002). Then call CommandExec.AnswerAutocomplete. Success sends no frame (asyncapi defines no reply); a failure sends error {op: "command.autocomplete_result", code, detail, ref} with the codes of note 7 and keeps the connection. detail names the pointer and the errors[].code of the first field error, never a value. If the merged dispatcher closes with 4000 after an op that answered error without an authorize check (note 7 a), restrict its proof to successful ops, as 02 9.3 does for HTTP and as 04 6.2 requires ("the connection stays"), with a test, and report it.

  10. Validation (runs/domain; codes in errors[] of FieldError, 06 5 point 3; the chain of M1-18 already rejects schema violations and unknown members):

    • Query: option must name an option of the command (/option, unknown_option) whose autocomplete is true (/option, autocomplete_not_enabled). Each options[i].name must name another option of the command (/options/<i>/name, unknown_option), at most once (duplicate), never option itself (duplicate). Values are passed on without a type check, because the person is still typing. The command's invokeCommand (M2-16) checks the final values.
    • Answer: more than command.autocomplete_max_choices choices: /choices, too_many_items. Each name and value is non-empty (/choices/<i>/name or /value, too_short) and passes kernel/textcheck.DisplayText (code invalid_utf8, control_character or bidi_control). Ruling for docs/DECISIONS.md: suggestion names and values are display texts in the composer, so SEC-IN-4 applies; length is bounded by the body limit of the chain.
    • Ruling for docs/DECISIONS.md: the errors[].code values unknown_option, autocomplete_not_enabled and duplicate; write them into 09 10.5.
  11. Configuration (09 21; extend M2-16's registration in internal/wire/configkeys_commands.go, Chapter 09-agents.md; command.autocomplete_max_choices already exists there and only gains its Max):

    • command.autocomplete_timeout: duration, positive.
    • command.autocomplete_max_choices: int, Min one, Max equal to the maxItems of GatewayAutocompleteResult.choices. A test reads that value from docs/api/openapi.yaml and compares it with the key's Max and with the maxItems of AutocompleteAnswer.choices and AutocompleteResult.choices. Ruling for docs/DECISIONS.md: a configured value above the schema bound could never be used over the gateway; one bound on all three schemas keeps REST and gateway equal (04 6.2 "equivalent to REST").
    • Cross-check autocomplete_below_request_timeout (AddCheck): command.autocomplete_timeout must be lower than http.request_timeout, otherwise the request context would end before the empty answer (02 14.2). Ruling for docs/DECISIONS.md.
    • Keys used: also sync.event_payload_max_bytes, sync.outbound_queue_max, agent.invocation_token_ttl (through M2-06), ratelimit.rl.trigger.*, ratelimit.rl.agent_runtime.*.
  12. Never a model, never a run (R-AG-14, 09 10.5 point 5). The server creates no run, no run_steps row, no run.state_changed, no durable event and no audit allow entry for autocomplete. The token carries no run_id, so M2-06's Verify with any RunID expectation answers invalid, and no startRun can accept it (09 4.5 "run wrong"). That the adapter's handler calls no model is the adapter's duty (M2-29, M2-30, M2-33, M2-34).

  13. Residual: confused deputy through suggestions (07 SR9; T-AI-02). SR9 binds API calls to the triggering person only "within a run". An adapter that reads the API with its own rights while it computes suggestions could return names from channels the person cannot read; the server cannot filter free-form choices. This ticket does not build around it: it records the residual in the threat model (Scope), the runs README and the report. M2-14 decides whether an autocomplete token authenticates requests as acting_for (spec gap and plan gap reported).

  14. Maintenance mode and idempotency. Both actions have effect read (05 2.7), so the maintenance layer (ADR 0065, M7-08) lets them through; nothing to build here. Both operations are idempotency-exempt (06 7 point 3); the generated route table carries x-idempotency-exempt, and no Idempotency-Key is required.

  15. Rulings for docs/DECISIONS.md (section 10, one row D-M2-17-<n> each, with date, reason and place): source port AutocompleteRelay and op handler in gateway (note 1); command outside its scope is 404 (note 2 d); payload over sync.event_payload_max_bytes gives 400 validation_failed with errors[].code too_long (note 2 h); empty list at once without a live connection and for app owners until M2-25 (notes 2 g and i; the forum case and the main thread as target are R-AZ-26, not rulings of this ticket); MentionsAgent = true (note 3); one connection, default first, then the newest (note 5); in-process registry with tombstones until expires_at plus command.autocomplete_timeout, and the multi-node limit (note 6); answer order with 404, 410 and 204 (note 7); error frames and the proof only for successful ops (note 8); validation codes and SEC-IN-4 for choices (note 9); key bounds tied to the schema and the cross-check (note 10).

Tests (write them first)

The test world is 07 "Examples", as in M2-08: spaces Support (text channel Allgemein), Marketing, Engineering (forum Projekt-A, text channel Werkzeuge), Research; agents Lotse (operator of Support, organization, chat), Texter (operator of Marketing, organization, scoped), Runner (operator of Engineering/Projekt-A and Engineering/Werkzeuge, organization, host), Assistent (operator of Research, personal, holder the Owner, scoped); one Owner; G-1 (Alex, trigger on Support), G-2 (Sam, trigger on Marketing); in addition Alex has trigger on Engineering/Werkzeuge and Robin has read on Support. Commands are registered with registerCommand and the agent's token (M2-16): Lotse /ticket in space Support with option ticket (string, autocomplete = true) and option priority (choice, static choices, autocomplete absent); Runner /deploy in Engineering with option target (autocomplete = true); Assistent /note in Research with option topic (autocomplete = true). Agent connections are wstest clients with the agent's token and a consumer name (M2-07), waited until catchup.complete. Tokens are checked with M2-06's reference verifier against getJwks. Short values for command.autocomplete_timeout come from the test configuration.

Unit:

  • internal/runs/domain: TestAutocompleteTarget (given thread; text channel without thread gives the main thread; forum without thread gives none); TestAutocompleteQueryValidation (unknown option, option without autocomplete, unknown name in options, a name twice, option repeated in options; each with pointer and code of note 9); TestAutocompleteAnswerValidation (over command.autocomplete_max_choices, empty name, empty value, a control character, a bidi control, a valid list); TestAutocompleteGrant (trigger_kind = autocomplete, no run_id, command_id, thread_id equals the payload's, level = trigger, target tool_class even when the reported class is higher); TestAutocompletePayload (validates against CommandAutocompleteRequestedPayload, no null, options absent when the query had none, expires_at equals now plus the key); TestAutocompleteRequestStates (open -> answered, open -> expired, no transition out of answered or expired, forgotten after expires_at plus the key).
  • internal/runs/app with fakes for the agents and chat ports, the relay, the authorizer and the kernel/clock test clock: TestAutocomplete_AG23_EmptyAfterTimeout (AG-23 at unit level: the relay takes the request, nobody answers, the clock passes command.autocomplete_timeout, the result is an empty list, not earlier); TestAutocomplete_NotSentGivesEmptyAtOnce; TestAutocomplete_AppOwnerAndForumWithoutThread (for an app owner the relay is never called; for an agent owner in a forum without thread_id the authorizer is still called, with NewThread = true, the relay is never called and the result is an empty list before the clock moves (R-AZ-26); a denial of the authorizer gives its error, not an empty list); TestAutocomplete_FirstAnswerWins; TestAutocomplete_TimeoutVersusAnswerRace (with -race: exactly one outcome); TestAutocomplete_ContextEndExpires (a later answer gives 410); TestAnswer_UnknownGives404WithoutAuthorize; TestAnswer_Order (404, then authorize, then 400, then 410 or 204).
  • internal/authz: TestCommandAutocompleteRequirement as a table through Authorize: Sam on Support gives not_found; Robin (read) gives forbidden level_insufficient; Alex for Lotse gives allow; a guest owner without allow_mentioned_agents gives agent_not_reachable; Lotse switched to personal with the Owner as holder gives billing_mode_personal_not_holder for Alex; Runner gives tool_class_owner_only for Alex and allow for the Owner; a read-only channel gives channel_read_only also for the Owner; an archived channel gives channel_archived; an agent principal as requester gives forbidden with level_insufficient (agents never reach trigger, 05 2.3 point 5); a DM gives level_insufficient; an app-owned command with required_level = write and a person with write gives allow without the invoke guard; a descriptor without Command is an internal error. TestCommandAutocompleteRespondRequirement: the owner agent gives allow; Texter with a read grant on Support gives forbidden not_self; Texter without it gives not_found; Alex gives forbidden subject_kind_not_allowed. TestDeferredActionsGolden no longer lists command.autocomplete.
  • internal/gateway/domain: TestPickAutocompleteConnection (default live wins; otherwise the newest live one; connections still catching up are skipped; none gives false).
  • internal/wire: TestAutocompleteKeys (the Max of command.autocomplete_max_choices equals the maxItems of GatewayAutocompleteResult.choices, AutocompleteAnswer.choices and AutocompleteResult.choices, read from docs/api/openapi.yaml; the cross-check rejects command.autocomplete_timeout not below http.request_timeout); golden files of op, route and event registrations.

Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, the gateway through wstest):

  • TestAutocomplete_AnswerOverGateway: Lotse connected with consumer default; Alex calls autocompleteCommand on Support/Allgemein without thread_id for ticket with value 47. Lotse gets exactly one dispatch of command.autocomplete_requested: no seq, no permission_version, audience: self, resource {kind: command, id} without version, scope with space, channel and main thread, correlation_id. The payload has request_id, command_id, channel_id, thread_id = main_thread_id, user_id Alex, option, value, expires_at. invocation_token passes the reference verifier with alg = EdDSA, typ = agent-invocation+jwt, a kid of getJwks, aud Lotse, sub Alex, trigger_kind = autocomplete, no run_id, command_id, thread_id, channel_id and space_id equal to the payload, level = trigger, billing_mode = organization, tool_class = chat, permission_version. Lotse sends command.autocomplete_result with two choices; Alex gets 200 with exactly these choices before the timeout; Lotse gets no error frame.
  • TestAutocomplete_AnswerOverREST: the same with answerAutocomplete: 204; Alex gets the choices.
  • TestAutocomplete_AG23_NoAnswerEmptyAfterTimeout (AG-23): Lotse is connected and silent. Alex gets 200 with {"choices": []} after at least command.autocomplete_timeout and within the key plus test slack. A later answerAutocomplete gets 410 gone; a later op answer gets error with gone, and the connection stays open.
  • TestAutocomplete_NoLiveConnectionEmptyAtOnce: Lotse not connected, and Lotse connected but still catching up: 200 with an empty list well before the timeout, no frame anywhere.
  • TestAutocomplete_ExactlyOneConnection: Lotse with live consumers default and audit, Alex and Sam connected as people: only default gets the frame. Without default, only the newer of two other consumers gets it.
  • TestAutocomplete_I2_PersonalAgent ("I2 applies here too"; 10 6; BR-04 style). (a) The Owner asks Assistent on Research: delivered, token billing_mode = personal, sub the Owner. (b) A raw SQL statement on the pgtest connection sets Lotse to personal with the Owner as holder while Alex keeps G-1, a fixture that simulates an I1 defect. Alex gets 403 agent_invocation_denied with reason: billing_mode_personal_not_holder; Lotse gets no frame; after the recorder flushes its aggregation window (a short audit.deny_aggregate_window in the test) there is exactly one deny entry for command.autocomplete with that reason.
  • TestAutocomplete_ActiveRunDoesNotBlock ("without a run"): Alex mentions Lotse in Support/Allgemein while Lotse is offline, so M2-08's acceptance creates the thread T' with a queued run of Lotse; Alex's request for /ticket with thread_id T' is delivered once Lotse is connected, with thread_id T' in payload and token; the run is unchanged.
  • TestAutocomplete_SR2_HostRunner: Alex on Engineering/Werkzeuge for /deploy gets 403 agent_invocation_denied with reason: tool_class_owner_only and Runner gets no frame; the Owner's request is delivered with tool_class = host.
  • TestAutocomplete_Rights (AZ-P4): Sam for /ticket on Support gets 404 with the same status, headers and problem body (apart from correlation_id and instance) as an unknown channel ID and as an unknown command ID, and the median response time differs by at most authz.not_found_timing_tolerance. Robin gets 403 agent_invocation_denied with reason: level_insufficient. A raw SQL statement on the pgtest connection sets channels.read_only = true for Support/Allgemein (the operation setChannelReadOnly comes with M8-03; the authz structure source reads read_only per request, M1-53 D7); then the Owner gets 403 channel_read_only; in an archived channel the reason is channel_archived. With M2-03 merged, the Owner's request for /deploy in a post of Projekt-A after its binding was set to not ready (through the port M2-03 offers for project_bindings.ready, as in M2-08) gives reason: project_not_ready; without M2-03 the report names this under "What is not proven".
  • TestAutocomplete_UnusableCommand: a removed command, a command scoped to Marketing called in Support, and a thread_id of another channel each give 404 identical to an unknown command.
  • TestAutocomplete_Validation: the cases of note 9 give 400 validation_failed with the pointers and codes listed; an unknown member gives unknown_member from the chain; with a low sync.event_payload_max_bytes a long value gives 400 with too_long and an empty pointer.
  • TestAutocomplete_ForumWithoutThread: with Runner connected and live (after catchup.complete), the Owner asks for /deploy in the forum Projekt-A without thread_id: 200 with an empty list well before command.autocomplete_timeout, no frame to Runner and no token (R-AZ-26); Alex (no grant on Projekt-A) gets the same 404 as for an unknown channel ID, so the check still runs; with the thread_id of a post: delivered with that thread_id. With M2-03 merged, Projekt-A has a ready binding here, as in M2-08.
  • TestAnswerAutocomplete_Rules: unknown request_id gives 404; Texter (with a read grant on Support) gives 403 forbidden with reason: not_self, without it 404; Alex's token gives 403 forbidden with reason: subject_kind_not_allowed; too many choices give 400 validation_failed with errors[].code too_many_items at /choices and a correct answer afterwards still counts; a control character in a name gives 400 validation_failed with errors[].code control_character at /choices/0/name; a second answer gives 204 and Alex keeps the first choices; the op variants answer error {op, code, ref} with the same codes and the connection stays.
  • TestAutocomplete_ConcurrentAnswers: an op answer and a REST answer at the same moment: exactly one set of choices reaches Alex; neither side gets an error.
  • TestAutocomplete_NothingStored (R-AG-14, SEC-LOG-4): after a delivered, a timed-out and a denied request, there is no new row in runs and run_steps, no new event_log row, no audit allow entry; the partial text, the option values, the choices and the token appear in no captured log line, no audit row and no event_log row.
  • TestAutocomplete_TokenIsNotARunToken: M2-06's server Verify with Audience Lotse and the RunID of a queued run of Lotse (created by M2-08's acceptance while Lotse was offline) answers reason: invalid for the autocomplete token.
  • TestAutocomplete_RateLimits: with low test values, rl.trigger exhausted gives 429 rate_limited with Retry-After on autocompleteCommand; rl.agent_runtime exhausted gives 429 on answerAutocomplete and error with rate_limited on the op.
  • TestAutocomplete_Registered (AZ-P1, AZ-P2): both routes are registered with their action and class; the op is registered with command.autocomplete.respond and is in gateway_ops.enabled; the event type is registered for runs; the start check passes; neither operation requires Idempotency-Key.
  • TestAutocomplete_DenialsAudited (AZ-P13): a denied autocompleteCommand and a denied answerAutocomplete each write one aggregated deny entry; allowed requests and the relay's admission write none.
  • TestAutocomplete_ContractShapes (G8, G9): every response of these tests passes the response validation of the test build, and every captured frame (dispatch, error, the op) matches its schema; no JSON null.

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./internal/runs/... ./internal/authz/... ./internal/kernel/... ./internal/gateway/... ./internal/agents/... ./internal/chat/... ./internal/wire/...
  • make codegen-check, make arch, make api-lint, make api-tables, make api-checks, make register-check, make catalog-check, make config-doc-check, make docs
  • make check

Acceptance criteria

  • autocompleteCommand and answerAutocomplete are enabled and registered with command.autocomplete (rl.trigger) and command.autocomplete.respond (rl.agent_runtime); the op command.autocomplete_result is in gateway_ops.enabled; the deferred entry of command.autocomplete is replaced; AZ-P1 and AZ-P2 pass.
  • AG-23: without an answer the person gets 200 with an empty list after command.autocomplete_timeout, not earlier; without a live connection of the owner at once.
  • I2 applies here too: a personal agent answers only its credential holder; another person gets 403 agent_invocation_denied with reason: billing_mode_personal_not_holder, the agent gets nothing, and the denial is audited.
  • SR2, SR4, the trigger right, read-only and archived channels are enforced; no read right gives the same 404 as an unknown ID.
  • The owner gets command.autocomplete_requested on exactly one live gateway connection, with a fresh token (trigger_kind = autocomplete, no run_id, command_id, thread_id equal to the payload) that the reference verifier accepts; in a forum channel without thread_id the agent gets nothing and the person gets an empty list at once after the check (R-AZ-26).
  • Answers over the op and over REST are equivalent; at most command.autocomplete_max_choices choices; the first valid answer wins; late answers get 410 gone, repeated ones 204, unknown ones 404.
  • No run, no durable event, no stored token and no logged partial text or choice comes from autocomplete.
  • docs/api/openapi.yaml, docs/spec/09-agents.md, docs/spec/02-architecture.md, the threat model, the READMEs and docs/DECISIONS.md describe what was built.
  • make check is 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, gateway and authz updated (port CommandExec with Autocomplete and AnswerAutocomplete, source port AutocompleteRelay, the open-request registry and its multi-node and restart limits, the op command.autocomplete_result, the requirement of command.autocomplete, configuration keys, the residual of Design note 12).
  • Rulings of Design note 14 recorded in docs/DECISIONS.md (one row each with reason).
  • services/server/CHANGELOG.md entry 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 check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. 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.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. 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>.