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 withVerdict: BLOCKED M1-66 not merged. Used only if merged: M2-03 (project readiness for theproject_not_readycase). 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_requestedis ephemeral), 2.2 "Envelope" (invocation_token,trace_context,correlation_id; noseqand nopermission_versionfor ephemeral events; payload rules 1 to 3), 6.2 "Ops" (rowcommand.autocomplete_result; a failing op answerserrorand 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_idof 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 (Auditno, every denial of a request is audited, R-AZ-04); 2.5 "Decision"; 2.6 "Scopes for apps" (command.autocomplete.respondneedscommands:write); 2.7 "Effect (effect)" (both actions areread); 3 "Rate-limit classes" (rl.trigger,rl.agent_runtime); 4.10 rowsagent.invoke,agent.events.consume,command.read,command.autocomplete,command.autocomplete.respond,command.invoke; 5 "Agent invocationagent.invoke" (points 1 to 8, the claim table:run_idabsent fortrigger_kind = autocomplete, and forcommand.invokedas R-AZ-25 says); 6 "Durable event types and receive action" (table "Ephemeral events", rowcommand.autocomplete_requested); 7 "WebSocket ops and action" (rowcommand.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,optionswithcommand_options.v1andautocomplete, 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";gatewayimplementskernel eventlog.EphemeralPublisher), 4.4 "Allowed dependencies", 4.6 "Actions per module" (bullet "command.invokeandcommand.autocompletebelong toruns"), 4.7 "Event types and WebSocket ops per module" (rowruns), 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 answer403 agent_invocation_deniedwithreason), 5 "Errors" (points 3 and 7; codesvalidation_failed,not_found,gone,agent_invocation_denied,channel_read_only,rate_limited), 7 "Idempotency" (point 3:autocompleteCommandisread_only,answerAutocompleteisagent_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" (rowcommands: "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: operationsautocompleteCommand,answerAutocomplete,invokeCommand,listChannelCommands,getCommand,registerCommand,getJwks; parametersChannelIdPath,CommandIdPath,RequestIdPath; schemasAutocompleteQuery,AutocompleteResult,AutocompleteAnswer,CommandChoice,CommandOption,CommandOptionValue,Command,CommandAutocompleteRequestedPayload,CommandAutocompleteRequestedEvent,GatewayAutocompleteResult(maxItemsofchoices),GatewayError,EventEnvelope,Channel(main_thread_id),Problem(x-problem-codes),FieldError; responsesBadRequest,Forbidden,NotFound,Gone,TooManyRequests.docs/api/asyncapi.yaml: operationreceive.command.autocomplete_result; messagesop.command.autocomplete_resultandevent.command.autocomplete_requested(x-event-kind: ephemeral,x-receive-action: agent.events.consume).docs/spec/actions.yaml(M1-02): entriescommand.autocomplete,command.autocomplete.respond, eventcommand.autocomplete_requested(receivers: agent_or_app), opcommand.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,FieldErrorcodesunknown_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.DisplayTextand its codesinvalid_utf8,control_character,bidi_control), M1-28 (Design notes "Public contract" withResourceandInvokeFacts, "Requirement table", "Deferred entries", "Guards" foragent.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 (dispatcherRegister(op, action, handler), decode rule4002,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 beforecatchup.complete", drop when the queue is full, decorator for M7-17), M1-07 and M1-08 (config.KeywithMinandMax,AddCheck,make config-reference). - M2 briefs: M2-04 (
InvokeFacts{AgentID, TriggerOrigin, MentionsAgent, ActiveRunAgentID}), M2-06 (Issue(ctx, InvocationGrant), the claim table with the rowautocomplete,Verify,runstest.NewReferenceVerifier,ReferenceExpectation, the redactingInvocationToken; its issuer refuses a zerothread_id, and R-AZ-26 keeps that: no autocomplete token is ever issued without a thread), M2-07 (consumer connections, the live phase aftercatchup.complete, the op handler pattern forack), M2-08 (InvokeFacts.NewThread, "the claims of a delivery", the world of its tests), M2-10 (pattern only: the op handlerrun.updateingateway/app), M2-16 (thecommandstable,agents.Commands,runs.CommandExec, the visibility of 09 10.3, the problem mapping ofinvokeCommand). - Code (merged names win, D-W-100):
services/server/architecture/modules.yaml(moduleruns: actionscommand.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.goandconfigchapters.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):
- 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:
autocompleteCommanduses the actioncommand.autocomplete: resourcechannel, permission "trigger andagent.invokewithout a run (for app commandsrequired_level)", Auditno, Permissionsno, classrl.trigger, effectread, noIdempotency-Key(x-idempotency-exempt: read_only) (05 4.10, 2.7; 06 7; openapi).- The request is checked like
agent.invokewithout a run, I2 included: the person is an active human (SR4), hastriggeron the channel, the channel is not a DM, not read-only and not archived, the owning agent is active and reachable (operator, or guest underallow_mentioned_agents),personalagents answer only their credential holder (I2), tool classhostonly 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). - No read right gives
404 not_found, identical to an unknown ID; a denial by the trigger right or byagent.invokegives403 agent_invocation_deniedwithreason; a read-only or archived channel gives403 channel_read_only; every denial of the request is audited and aggregated peraudit.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). - 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, withoutrun_id, triggering objectcommand_id) as the delivery fieldinvocation_token. Payload and token carry asthread_idthe thread of the request or, in a text channel without one, the main thread; in a forum channel withoutthread_idnothing 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 rowthread_id; R-AZ-26). The event has noseq, 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; 03event_log; R-SY-07, R-SY-17). - The owner answers with the op
command.autocomplete_result {request_id, choices}or withanswerAutocomplete. Both use the actioncommand.autocomplete.respond(resourcecommand,agent-selforapp-selfas owner of the command, classrl.agent_runtime, effectread, app scopecommands:write, exemptagent_runtime), with at mostcommand.autocomplete_max_choicesentries (04 6.2, 15.7 point 3; 05 2.6, 4.10, 7; 06 7; asyncapireceive.command.autocomplete_result). - Without an answer within
command.autocomplete_timeoutthe server answers200with an empty list (AG-23; 04 15.7 point 4; 09 10.5 point 4; R-AG-14). - Autocomplete never calls a model and never costs anything: no run, no
runsrow, no durable event, no cost entry. The token cannot start a run, because it has norun_id(09 10.5 point 5; R-AG-14; D-AG-13; 05 5). - No JSON
nullin bodies, frames or the event; every response, frame and payload matches its schema (ADR 0053; R-API-03; 06 15 G8 and G9). - 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 DTOsAutocompleteQuery,AutocompleteResult,AutocompleteAnswer,Choice,OptionValue; the two methods thatCommandExecgains (Design note 1); the source portAutocompleteRelay(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 forCommandAutocompleteRequestedPayload, theInvocationGrantbuilder fortrigger_kind = autocomplete(Design note 4), the request state machineopen -> answered | expiredwith the tombstone rule (Design note 6).internal/runs/app/autocomplete.go: the use casesAutocompleteandAnswerAutocompleteand the in-process registry of open requests (Design notes 2, 6 and 7).internal/runs/adapters/httpapi/autocomplete.go: the strict-server methods forautocompleteCommandandanswerAutocomplete.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 opcommand.autocomplete_result(Design note 8).internal/gateway/adapters/autocompleterelay/: implementsruns.AutocompleteRelayon the gateway app, asconsumerfeedimplementsagents.ConsumerFeed(M2-07).
Change:
internal/kernel/authorize: if M2-16 did not add command facts toResource, addCommand *CommandFactswithCommandFacts{OwnerID ids.ID; OwnerKind string; RequiredLevel string}and a doc comment (Design note 3).internal/authz: replace the deferred entry ofcommand.autocompletewith the requirement of Design note 3; remove it fromTestDeferredActionsGolden; tests.command.autocomplete.respondkeeps the M1-28 ruleAny(AgentSelf, AppSelf).internal/runsregistration (Register(r *authorize.Registry)of the port): the ephemeral event typecommand.autocomplete_requestedfor moduleruns(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 withoutauthorizefor callers that authorize themselves (Commands.Info(ctx, id)or the merged name, likechat.Threads.Info), returning owner, options, scope andremoved_at; the rule "the command's scope covers this channel" if M2-16 did not export it.internal/wire: bindautocompleterelayintoruns; register the opcommand.autocomplete_resultwith actioncommand.autocomplete.respondin the dispatcher of M1-64; embed the two HTTP handlers; registercommand.autocomplete_timeout, add theMaxtocommand.autocomplete_max_choicesand add the cross-check; update the golden files of registrations.internal/wire/configkeys_commands.go(created by M2-16): registercommand.autocomplete_timeout(duration, positive, chapter09-agents.md) there. Give the existing registration ofcommand.autocomplete_max_choicestheMaxof Design note 10 and do not register it a second time (NewSchemarejects duplicate names, M1-07). Add the cross-checkautocomplete_below_request_timeouttoconfigChecksinconfigchecks.go. Add the constantchapter09toconfigchapters.goif no earlier ticket added it.api/operations.yaml: moveautocompleteCommandandanswerAutocompletefromlatertoenabled(sorted) andcommand.autocomplete_resultfromgateway_ops.latertogateway_ops.enabled; rerun the route generator (make codegen-checkshows no diff afterwards).docs/api/openapi.yaml(spec first):maxItemsonAutocompleteAnswer.choicesandAutocompleteResult.choices, equal to the one onGatewayAutocompleteResult.choices, each with the description "At mostcommand.autocomplete_max_choices."; descriptions ofautocompleteCommand(empty list at once without a live connection of the owner, and for an agent-owned command in a forum channel withoutthread_idafter the check, R-AZ-26;403 agent_invocation_deniedreasons;404for an unknown or unusable command) and ofanswerAutocomplete(404unknown request,410 goneafter the wait ended,204without effect for a later answer,400rules of Design note 9). Thenmake api-lint,make api-tables,make api-checks.docs/spec/09-agents.md10.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, whereinteractions_urlis used).docs/spec/02-architecture.md4.3: rowruns"declaresAutocompleteRelay", rowgateway"Implements sources:runs.AutocompleteRelay".docs/operations/configuration.md: regenerate section 4 withmake config-referenceif the keys were not registered before;make config-doc-checkgreen.docs/security/threat-model.md5.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,gatewayandauthz(English, like the existing files);services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M2-17-1and following (Design note 14).
Out of scope¶
- Registering, changing, removing, listing and running commands, the
commandstable, reserved names, control and model commands,command.changed,command.invokedand its token: M2-16. - Autocomplete for app-owned commands: the app's gateway connection with
events:subscribe, the synchronousPOSTtointeractions_urlwithinapp.interaction_timeout, the admission rule for apps (the registered receive action isagent.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
commandswith 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 nocommandsrow and nocommand_id, soautocompleteCommandcannot 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 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. 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
wstestclient of M1-64 with the agent's API token and a consumer name (M2-07), or plain HTTP with that token and DPoP proofs fromidentitytest. - Time-dependent tests set short values for
command.autocomplete_timeoutthrough the configuration loader or drive the wait with thekernel/clocktest 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 needsthread_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¶
-
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 asthread_id, or the main thread in a text channel withoutthread_id. In a forum channel withoutthread_idthe 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 testsTestAutocomplete_AppOwnerAndForumWithoutThreadandTestAutocomplete_ForumWithoutThreadbuild exactly this. -
Placement and ports (02 4.3, 4.4, 4.6, 4.7, 7.3; 02 4.1 point 4).
runsowns both actions, the ephemeral event and the op (modules.yaml). The portruns.CommandExecof 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 }runsdeclares the source port andgatewayimplements it (02 4.1 point 4;gatewaymay useruns, 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) }invocationTokenis the revealed string of M2-06'sInvocationToken, as in M2-08'sDeliveryOutcome; empty for app owners. Ruling fordocs/DECISIONS.md: a source port ofruns, not a new kernel port, because the one-connection rule, the report back and the delivery field belong to this use case only; precedentagents.ConsumerFeed(M2-07). The op handler lives ingateway/appand callsCommandExec.AnswerAutocomplete, asack(M2-07) andrun.update(M2-10) do;authorizeruns insideruns. -
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 theagentsport (M2-16). Unknown, orremoved_atset:404 not_found. b. Thread: ifThreadIDis set, readchat.Threads.Info; unknown, or its channel is notChannelID:404 not_found. This check runs beforeauthorize, because M1-28 treats contradictory path hints as an internal error. c. Channel facts through thechatport (kind, space,main_thread_id); unknown:404 not_found. d. The command's scope (scope_kindwithscope_space_idorscope_channel_id, 03commands) must cover the channel; otherwise404 not_found. Ruling fordocs/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 byauthorize(agent_not_reachable), not byruns. e. Target thread (note 3) andauthorize(note 3), with the rights version read once throughinstance.Versionsand pinned withauthorize.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 fromcorrelation_idandinstance(AZ-P4). f. Query validation (note 9):400 validation_failed. g. Answer200with 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 withoutthread_id; R-AZ-26, note 0). h. Build the payload (note 4); if the encoded payload exceedssync.event_payload_max_bytes:400 validation_failedwitherrors[].codetoo_long, an empty pointer (a rule of the whole request) anddetailnaming the key. Ruling fordocs/DECISIONS.md: truncation (04 2.2 rule 2) makes no sense for a partial text, andPublishEphemeralwould only report a programming fault. i. Issue the token (note 4), register the open request (note 6), then callAutocompleteRelay.Ask.sent = false: remove the request and answer200with an empty list at once. Ruling fordocs/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 gives200with its choices; the end ofcommand.autocomplete_timeoutgives200with an empty list; the end of the request context (client gone,http.request_timeout, shutdown) marks the request expired and writes nothing. -
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 asthread_id; else (forum) none, and note 2 g answers. So the adapter's check of 09 4.5 point 6 compares equalthread_idvalues 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>}}.OwnerKindcomes from theagentsport: an agent record exists for agent owners (M1-51), every other owner is an app. - Requirement of
command.autocompleteinauthz(replacesDeferred, 07 4.2): owner kindagent:Level(trigger)and theagent.invokeguard of M1-28 steps 1 to 6 on the target thread (on the channel with the main-thread rule of M2-08'sNewThreadwhen there is none); step 7 never applies becauseActiveRunAgentIDis zero ("without a run", 05 4.10). Owner kindapp:Level(<RequiredLevel>)only, withoutagent.invokeand I2 (R-AZ-18). A descriptor withoutCommand, or an agent owner withoutInvoke, is an internal error. The read-only and archive rules of M1-28 run as for everytriggeraction (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 needsallow_mentioned_agents,writeandorganization(09 10.3; R-AZ-19). Ruling fordocs/DECISIONS.md. If M2-16 decided otherwise forcommand.invoke, follow M2-16 and report it.- Problem mapping of
command.autocomplete(06 4 point 6):not_foundgives404 not_found;forbiddenwithchannel_read_onlyorchannel_archivedgives403 channel_read_onlywithreason;device_pendingandscope_missingkeep their codes (M1-28 mapping); every otherforbiddenreason (level_insufficient,origin_not_human,agent_not_reachable,billing_mode_personal_not_holder,tool_class_owner_only,project_not_ready,dev_environment_not_ready) gives403 agent_invocation_deniedwithreason. Reuse the helper with which M2-16 mapsinvokeCommand; if it has none, add one inrunsand use it for both. command.autocomplete.respond:Resource{Kind: command, ID: CommandID, ChannelID, ThreadID: <target thread>, OwnerID: owner}; M1-28 decidesAny(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 needcommands:write(05 2.6).
- Target thread (R-AZ-26): the given thread; else, in a text channel, the channel's main thread (
-
Token and event.
- Grant for M2-06's
Issue:AgentIDowner,TriggeredBythe person,TriggerKindautocomplete, noRunID,CommandID,ThreadIDthe target thread,ChannelID,SpaceIDthe channel's space,Leveltrigger(anallowimplies effectivetrigger, as in M2-08),BillingModeandToolClassthe current target of the agent through theagentsport (never the reported class),PermissionVersionthe 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:Typecommand.autocomplete_requested,SchemaVersionfrom the register,Actor{id: person, kind: user},Scope{space_id, channel_id, thread_id},Resource{kind: command, id: command_id}withoutversion,Audiencethe owner. The envelope has a new UUIDv7id,occurred_at, the request'scorrelation_id,trace_contextwhen the request is recorded (04 2.2), the delivery fieldinvocation_token, and noseqand nopermission_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, nevernull),expires_at= now pluscommand.autocomplete_timeout(RFC 3339 UTC with milliseconds). No message content, no prompt (04 2.2 rule 1).
- Grant for M2-06's
-
Relay (
gateway). Candidates: connections ofownerIDin this process that are in the live phase. For agents that means after the consumer window sentcatchup.complete(M2-07 note 5), and ephemeral frames never go out before it (M1-66). Choose exactly one: the connection of consumerdefaultif it is live, otherwise the most recently attached live connection. Ruling fordocs/DECISIONS.md:defaultis 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 actionagent.events.consumeand audience self, inauthorize.Delivery(ctx)mode (filter, no audit), as M1-65 and M1-66 admit personal events. Enqueue without blocking; if the frame would exceedsync.outbound_queue_maxit is dropped (never4014, M1-66) andAskreturnsfalse. Person connections and other principals never get the frame.Askreturns an error only for programming faults (unregistered type, oversized payload);runsturns that into500 internal_error. -
Open requests (
runs/app, in process memory; a mutex-guarded map keyed byrequest_id):- Entry: owner, command, channel, target thread,
expires_at, stateopen,answeredorexpired, 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/clockset tocommand.autocomplete_timeout, andctx.Done(). Every transition is a compare-and-swap under the mutex:open -> answeredby the first valid answer,open -> expiredby 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_atpluscommand.autocomplete_timeout, then they are forgotten. EveryAutocompleteandAnswerAutocompletecall sweeps them; there is no background goroutine. Ruling fordocs/DECISIONS.md:410 goneneeds a memory of the request (06 5 "autocomplete"), and the existing key bounds that memory without a new number;rl.triggerbounds 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 = multibefore 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 gets404. Name this limit in therunsREADME (OQ-07; RK-37).
- Entry: owner, command, channel, target thread,
-
Use case
AnswerAutocomplete, in this order: a. Find the entry. Unknown, never issued by this process, or forgotten:404 not_found, withoutauthorize(an unknown resource ID, as M1-62 note 1 a; answers of400and 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 forbiddenwithreasonnot_self(another agent that can read the place) orsubject_kind_not_allowed(a person);403 scope_missingfor an app. c. Answer validation (note 9):400 validation_failed; the request stays open, so a corrected answer within the window still counts. d. Stateexpired:410 gone. Stateanswered:204without effect (R-API-27). Ruling fordocs/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-swapopen -> answeredwith the choices, wake the waiter, answer204. Two answers at the same moment, over REST and over the op, give exactly one accepted answer. No audit entry for an allowed answer (Auditno); denials are audited byauthz. -
Gateway op
command.autocomplete_result(04 6.2; asyncapireceive.command.autocomplete_result). Register it in the dispatcher of M1-64 with actioncommand.autocomplete.respondand move it togateway_ops.enabled. Rate limitrl.agent_runtimekeyed by the principal before anything else. A frame that breaksGatewayAutocompleteResultfollows M1-64's decode rule (4002). Then callCommandExec.AnswerAutocomplete. Success sends no frame (asyncapi defines no reply); a failure sendserror {op: "command.autocomplete_result", code, detail, ref}with the codes of note 7 and keeps the connection.detailnames the pointer and theerrors[].codeof the first field error, never a value. If the merged dispatcher closes with4000after an op that answerederrorwithout anauthorizecheck (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. -
Validation (
runs/domain; codes inerrors[]ofFieldError, 06 5 point 3; the chain of M1-18 already rejects schema violations and unknown members):- Query:
optionmust name an option of the command (/option,unknown_option) whoseautocompleteistrue(/option,autocomplete_not_enabled). Eachoptions[i].namemust name another option of the command (/options/<i>/name,unknown_option), at most once (duplicate), neveroptionitself (duplicate). Values are passed on without a type check, because the person is still typing. The command'sinvokeCommand(M2-16) checks the final values. - Answer: more than
command.autocomplete_max_choiceschoices:/choices,too_many_items. Eachnameandvalueis non-empty (/choices/<i>/nameor/value,too_short) and passeskernel/textcheck.DisplayText(codeinvalid_utf8,control_characterorbidi_control). Ruling fordocs/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: theerrors[].codevaluesunknown_option,autocomplete_not_enabledandduplicate; write them into 09 10.5.
- Query:
-
Configuration (09 21; extend M2-16's registration in
internal/wire/configkeys_commands.go,Chapter09-agents.md;command.autocomplete_max_choicesalready exists there and only gains itsMax):command.autocomplete_timeout: duration, positive.command.autocomplete_max_choices: int,Minone,Maxequal to themaxItemsofGatewayAutocompleteResult.choices. A test reads that value fromdocs/api/openapi.yamland compares it with the key'sMaxand with themaxItemsofAutocompleteAnswer.choicesandAutocompleteResult.choices. Ruling fordocs/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_timeoutmust be lower thanhttp.request_timeout, otherwise the request context would end before the empty answer (02 14.2). Ruling fordocs/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.*.
-
Never a model, never a run (R-AG-14, 09 10.5 point 5). The server creates no run, no
run_stepsrow, norun.state_changed, no durable event and no audit allow entry for autocomplete. The token carries norun_id, so M2-06'sVerifywith anyRunIDexpectation answersinvalid, and nostartRuncan 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). -
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
runsREADME and the report. M2-14 decides whether an autocomplete token authenticates requests asacting_for(spec gap and plan gap reported). -
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 carriesx-idempotency-exempt, and noIdempotency-Keyis required. -
Rulings for
docs/DECISIONS.md(section 10, one rowD-M2-17-<n>each, with date, reason and place): source portAutocompleteRelayand op handler ingateway(note 1); command outside its scope is404(note 2 d); payload oversync.event_payload_max_bytesgives400 validation_failedwitherrors[].codetoo_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,defaultfirst, then the newest (note 5); in-process registry with tombstones untilexpires_atpluscommand.autocomplete_timeout, and the multi-node limit (note 6); answer order with404,410and204(note 7);errorframes 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(unknownoption,optionwithoutautocomplete, unknown name inoptions, a name twice,optionrepeated inoptions; each with pointer and code of note 9);TestAutocompleteAnswerValidation(overcommand.autocomplete_max_choices, empty name, empty value, a control character, a bidi control, a valid list);TestAutocompleteGrant(trigger_kind = autocomplete, norun_id,command_id,thread_idequals the payload's,level = trigger, targettool_classeven when the reported class is higher);TestAutocompletePayload(validates againstCommandAutocompleteRequestedPayload, nonull,optionsabsent when the query had none,expires_atequals now plus the key);TestAutocompleteRequestStates(open -> answered,open -> expired, no transition out ofansweredorexpired, forgotten afterexpires_atplus the key).internal/runs/appwith fakes for theagentsandchatports, the relay, the authorizer and thekernel/clocktest clock:TestAutocomplete_AG23_EmptyAfterTimeout(AG-23 at unit level: the relay takes the request, nobody answers, the clock passescommand.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 withoutthread_idthe authorizer is still called, withNewThread = 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 gives410);TestAnswer_UnknownGives404WithoutAuthorize;TestAnswer_Order(404, thenauthorize, then400, then410or204).internal/authz:TestCommandAutocompleteRequirementas a table throughAuthorize: Sam on Support givesnot_found; Robin (read) givesforbiddenlevel_insufficient; Alex for Lotse givesallow; a guest owner withoutallow_mentioned_agentsgivesagent_not_reachable; Lotse switched topersonalwith the Owner as holder givesbilling_mode_personal_not_holderfor Alex; Runner givestool_class_owner_onlyfor Alex andallowfor the Owner; a read-only channel giveschannel_read_onlyalso for the Owner; an archived channel giveschannel_archived; an agent principal as requester givesforbiddenwithlevel_insufficient(agents never reachtrigger, 05 2.3 point 5); a DM giveslevel_insufficient; an app-owned command withrequired_level = writeand a person withwritegivesallowwithout the invoke guard; a descriptor withoutCommandis an internal error.TestCommandAutocompleteRespondRequirement: the owner agent givesallow; Texter with areadgrant on Support givesforbiddennot_self; Texter without it givesnot_found; Alex givesforbiddensubject_kind_not_allowed.TestDeferredActionsGoldenno longer listscommand.autocomplete.internal/gateway/domain:TestPickAutocompleteConnection(defaultlive wins; otherwise the newest live one; connections still catching up are skipped; none gives false).internal/wire:TestAutocompleteKeys(theMaxofcommand.autocomplete_max_choicesequals themaxItemsofGatewayAutocompleteResult.choices,AutocompleteAnswer.choicesandAutocompleteResult.choices, read fromdocs/api/openapi.yaml; the cross-check rejectscommand.autocomplete_timeoutnot belowhttp.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 consumerdefault; Alex callsautocompleteCommandon Support/Allgemein withoutthread_idforticketwith value47. Lotse gets exactly onedispatchofcommand.autocomplete_requested: noseq, nopermission_version,audience: self,resource {kind: command, id}withoutversion,scopewith space, channel and main thread,correlation_id. The payload hasrequest_id,command_id,channel_id,thread_id=main_thread_id,user_idAlex,option,value,expires_at.invocation_tokenpasses the reference verifier withalg = EdDSA,typ = agent-invocation+jwt, akidofgetJwks,audLotse,subAlex,trigger_kind = autocomplete, norun_id,command_id,thread_id,channel_idandspace_idequal to the payload,level = trigger,billing_mode = organization,tool_class = chat,permission_version. Lotse sendscommand.autocomplete_resultwith two choices; Alex gets200with exactly these choices before the timeout; Lotse gets noerrorframe.TestAutocomplete_AnswerOverREST: the same withanswerAutocomplete:204; Alex gets the choices.TestAutocomplete_AG23_NoAnswerEmptyAfterTimeout(AG-23): Lotse is connected and silent. Alex gets200with{"choices": []}after at leastcommand.autocomplete_timeoutand within the key plus test slack. A lateranswerAutocompletegets410 gone; a later op answer getserrorwithgone, and the connection stays open.TestAutocomplete_NoLiveConnectionEmptyAtOnce: Lotse not connected, and Lotse connected but still catching up:200with an empty list well before the timeout, no frame anywhere.TestAutocomplete_ExactlyOneConnection: Lotse with live consumersdefaultandaudit, Alex and Sam connected as people: onlydefaultgets the frame. Withoutdefault, 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, tokenbilling_mode = personal,subthe Owner. (b) A raw SQL statement on thepgtestconnection sets Lotse topersonalwith the Owner as holder while Alex keeps G-1, a fixture that simulates an I1 defect. Alex gets403 agent_invocation_deniedwithreason: billing_mode_personal_not_holder; Lotse gets no frame; after the recorder flushes its aggregation window (a shortaudit.deny_aggregate_windowin the test) there is exactly one deny entry forcommand.autocompletewith that reason.TestAutocomplete_ActiveRunDoesNotBlock("without a run"): Alex mentions Lotse in Support/Allgemein while Lotse is offline, so M2-08's acceptance creates the threadT'with a queued run of Lotse; Alex's request for/ticketwiththread_idT'is delivered once Lotse is connected, withthread_idT'in payload and token; the run is unchanged.TestAutocomplete_SR2_HostRunner: Alex on Engineering/Werkzeuge for/deploygets403 agent_invocation_deniedwithreason: tool_class_owner_onlyand Runner gets no frame; the Owner's request is delivered withtool_class = host.TestAutocomplete_Rights(AZ-P4): Sam for/ticketon Support gets404with the same status, headers and problem body (apart fromcorrelation_idandinstance) as an unknown channel ID and as an unknown command ID, and the median response time differs by at mostauthz.not_found_timing_tolerance. Robin gets403 agent_invocation_deniedwithreason: level_insufficient. A raw SQL statement on thepgtestconnection setschannels.read_only = truefor Support/Allgemein (the operationsetChannelReadOnlycomes with M8-03; the authz structure source readsread_onlyper request, M1-53 D7); then the Owner gets403 channel_read_only; in an archived channel the reason ischannel_archived. With M2-03 merged, the Owner's request for/deployin a post of Projekt-A after its binding was set to not ready (through the port M2-03 offers forproject_bindings.ready, as in M2-08) givesreason: 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 athread_idof another channel each give404identical to an unknown command.TestAutocomplete_Validation: the cases of note 9 give400 validation_failedwith the pointers and codes listed; an unknown member givesunknown_memberfrom the chain; with a lowsync.event_payload_max_bytesa longvaluegives400withtoo_longand an empty pointer.TestAutocomplete_ForumWithoutThread: with Runner connected and live (aftercatchup.complete), the Owner asks for/deployin the forum Projekt-A withoutthread_id:200with an empty list well beforecommand.autocomplete_timeout, no frame to Runner and no token (R-AZ-26); Alex (no grant on Projekt-A) gets the same404as for an unknown channel ID, so the check still runs; with thethread_idof a post: delivered with thatthread_id. With M2-03 merged, Projekt-A has a ready binding here, as in M2-08.TestAnswerAutocomplete_Rules: unknownrequest_idgives404; Texter (with areadgrant on Support) gives403 forbiddenwithreason: not_self, without it404; Alex's token gives403 forbiddenwithreason: subject_kind_not_allowed; too many choices give400 validation_failedwitherrors[].codetoo_many_itemsat/choicesand a correct answer afterwards still counts; a control character in a name gives400 validation_failedwitherrors[].codecontrol_characterat/choices/0/name; a second answer gives204and Alex keeps the first choices; the op variants answererror {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 inrunsandrun_steps, no newevent_logrow, 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 noevent_logrow.TestAutocomplete_TokenIsNotARunToken: M2-06's serverVerifywithAudienceLotse and theRunIDof a queued run of Lotse (created by M2-08's acceptance while Lotse was offline) answersreason: invalidfor the autocomplete token.TestAutocomplete_RateLimits: with low test values,rl.triggerexhausted gives429 rate_limitedwithRetry-AfteronautocompleteCommand;rl.agent_runtimeexhausted gives429onanswerAutocompleteanderrorwithrate_limitedon the op.TestAutocomplete_Registered(AZ-P1, AZ-P2): both routes are registered with their action and class; the op is registered withcommand.autocomplete.respondand is ingateway_ops.enabled; the event type is registered forruns; the start check passes; neither operation requiresIdempotency-Key.TestAutocomplete_DenialsAudited(AZ-P13): a deniedautocompleteCommandand a deniedanswerAutocompleteeach 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 JSONnull.
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 docsmake check
Acceptance criteria¶
-
autocompleteCommandandanswerAutocompleteare enabled and registered withcommand.autocomplete(rl.trigger) andcommand.autocomplete.respond(rl.agent_runtime); the opcommand.autocomplete_resultis ingateway_ops.enabled; the deferred entry ofcommand.autocompleteis replaced; AZ-P1 and AZ-P2 pass. - AG-23: without an answer the person gets
200with an empty list aftercommand.autocomplete_timeout, not earlier; without a live connection of the owner at once. - I2 applies here too: a
personalagent answers only its credential holder; another person gets403 agent_invocation_deniedwithreason: 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
404as an unknown ID. - The owner gets
command.autocomplete_requestedon exactly one live gateway connection, with a fresh token (trigger_kind = autocomplete, norun_id,command_id,thread_idequal to the payload) that the reference verifier accepts; in a forum channel withoutthread_idthe 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_choiceschoices; the first valid answer wins; late answers get410 gone, repeated ones204, unknown ones404. - 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 anddocs/DECISIONS.mddescribe what was built. -
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,gatewayandauthzupdated (portCommandExecwithAutocompleteandAnswerAutocomplete, source portAutocompleteRelay, the open-request registry and its multi-node and restart limits, the opcommand.autocomplete_result, the requirement ofcommand.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.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>.