M2-03: Project binding and readiness¶
Status: ready | Depends on: M2-02 | Milestone: M2
Goal¶
After this ticket an owner with a fresh step-up binds a forum to a development project and removes that binding again (putProjectBinding, deleteProjectBinding, action channel.project.bind). The binding is a row of project_bindings in the module chat: an opaque workspace_ref that the runner resolves, a display-only repository_url, a default_branch, a runner_config after the schema project_runner_config.v1, and a ready flag that only the serving runner sets through its heartbeat, by a port this ticket provides. chat feeds ProjectBound and ProjectReady into the facts of authorize, so agent.invoke in a bound forum is denied with project_not_ready until the runner has reported its workspace ready, and every binding change is announced only as a new settings_version (channel.settings_changed, agent.config_changed), never with its content. This is the function "Dev forum with project binding and runner agent" of 01 ("triggering only with project_bindings.ready") and point 7 of 05 "Agent invocation agent.invoke"; the heartbeat (M2-20), the trigger decision (M2-04), the delivery check (M2-08) and the Claude-Code runner (M2-34) build on it.
Read first¶
docs/spec/03-data-model.md: "Principles" points 7, 8, 9, 10, 12 and 13; tableproject_bindings(every row, PK, uniquelower(workspace_ref), FKs, retentionconfig); tablechannels(kind,archived_at,settings_version, the formula "Effective operator");spaces.default_operator_agent_id;event_log(audience_principal_id,receive_action,resource_version);audit_events.details; "Transaction rules" 6.3 "Lock order".docs/spec/05-actions.md: rules 1.1, 1.2 and 1.8; 2.1 (valueowner), 2.3 "Effective level" point 1, 2.4 'Columns "Owner", "Step-up", "Audit", "Permissions"'; 4.5 rowschannel.project.bind,channel.behavior.read,channel.behavior.update,channel.operator.set; 4.10 rowsagent.invoke,agent.heartbeat,agent.config.read; section 5 "Agent invocationagent.invoke" (point 7 is the readiness rule; the plan row cites it as point 6, point 6 is about interactions); section 6 rowschannel.settings_changedandagent.config_changed; section 8 R-AZ-01, R-AZ-03, R-AZ-04, R-AZ-09, R-AZ-10, R-AZ-14.docs/spec/07-permissions.md: 4.1 "Decisions and reasons" rowsowner_only,step_up_not_possible,step_up_required,project_not_ready,dev_environment_not_ready,channel_archived; 4.2 "Flow"; 4.5 "Archived spaces and channels"; SR2 "Tool class", SR3 "Dev isolation", SR5; 10 "Examples" (Runner as operator of the forum Engineering/Projekt-A with project binding); 11.1 "Test matrix" rows AZ-M15 (owned by V2-02) and AZ-M19 (owned by M2-08); 11.2 "Required tests" AZ-P1, AZ-P4, AZ-P13.docs/spec/09-agents.md: 3.4 "Decision per message" point 7; 3.5 "Reasons for rejection at acceptance" rowproject_not_ready; 4.4 "Events to the adapter" rowagent.config_changedand the payload rule below the table; 4.8 "Heartbeat and reported state" rowprojects; 5.2 "Reasons (runs.state_reason)" rowproject_not_ready; 18.1 "Project and thread"; 18.2 "Process contract" (--model, the flags per tool class); 21 "Configuration values of this chapter".docs/spec/04-sync-protocol.md: 2.2 "Envelope" (fieldaudience, payload rule 1: project binding never in events); 9 "Delivery guarantee" point 3 (noresource.versionforchannel.settings_changedandagent.config_changed).docs/spec/06-api.md: 2 "Basic rules" row "Unknown values"; 4 "Authorization in the API" points 3 and 6; 5 "Errors" (errors[]withpointerforms, the closed code list); 7 "Idempotency" row "PUT and DELETE"; 8 "Concurrency" points 1, 3 and 5.docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rowchat:AgentConfig, implementsauthz.StructureSource), 4.4 "Allowed dependencies" (agentsmay usechat), 4.6 "Actions per module" (agent.config.readbelongs tochat), 8.1 to 8.3 (unit of work, lock order, in-transaction events).docs/spec/19-security.md: SEC-IN-1, SEC-IN-4, SEC-OUT-1, SEC-AUD-5, SEC-T07.docs/spec/21-dev-isolation.md: status paragraph and section 1 (until V2 only owners trigger dev projects).docs/spec/01-product.md: 3.1 "Structure and conversations" row "Dev forum with project binding and runner agent".docs/api/openapi.yaml: operationsputProjectBinding,deleteProjectBinding,getChannelBehavior,putChannelBehavior,listServedChannels,getServedChannelConfig; schemasProjectBinding,ProjectBindingPut,ChannelBehavior(memberproject_binding),AgentChannelConfig,AgentChannelConfigList,Heartbeat(memberproject_readiness),ProjectReadiness,ChannelSettingsChangedEvent,ChannelSettingsChangedPayload,AgentConfigChangedEvent,AgentConfigChangedPayload,JsonObject,Problem,FieldError; responsesConflict,PreconditionFailed; parametersChannelIdPath,IfMatchOptional.docs/api/asyncapi.yaml: messagesevent.channel.settings_changedandevent.agent.config_changed(x-audience: self).docs/api/webhooks.md: rowschannel.settings_changedandagent.config_changed.docs/spec/actions.yaml: entrychannel.project.bindand the event entrieschannel.settings_changed,agent.config_changed.- ADRs 0013 (spec first), 0017 (one authorize function, 404 before 403), 0022 (agents outside the server process), 0046 (multi-user dev environment only with isolation), 0053 (no JSON
null). docs/DECISIONS.md: D-W-100 (briefs written before M1 closed), section 10 (format ofD-<ticket>-<n>rows).docs/security/threat-model.md: T-AI-02 (confused deputy), T-DEV-01 (dev runner as a back door to the host), T-AZ-01, T-AZ-04, T-IN-01.docs/operations/configuration.md:audit.reason_max_chars,auth.step_up_max_age,database.tx_retry_max,http.max_json_body_bytes,audit.deny_aggregate_window.docs/PLAN.md: section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope "09 without dev isolation", Out of scope "SR3 and dev environments (V2)", Red team); rows M2-02, M2-03, M2-04, M2-08, M2-20, M2-34 and V2-02.- M1 briefs whose code this ticket uses: M1-28 (
authzsources:ChannelFacts.ProjectBound,ChannelFacts.ProjectReady; guardagent.invokestep 6; decision to error mapping), M1-53 (D2 archived channels, D7 channel part ofStructureSource, migration pattern), M1-54 (operator use cases, D2 order of checks, the world builder underservices/server/test/integration/), M1-55 (thread part ofStructureSource,chat.Threads.Create), M1-62 (structure of a chat use case), M1-17 (api/operations.yaml), M1-18 (W7 closed request schemas, field error codes,If-Matchhelpers), M1-13 (txn.UnitOfWork), M1-16 (eventlog.Appender), M1-24 (auditing.Recorder, deny aggregator), M1-26 (kernel/textcheck), M1-35 (step-up,identitytest), M1-07 and M1-08 (configuration keys and reference). - Code:
internal/chat(port, spaces, channels, operators, threads, behaviour of M2-02),internal/authz(decide, guards, sources),internal/kernel/authorize,internal/kernel/auditing,internal/kernel/eventlog,internal/kernel/txn,internal/kernel/textcheck,internal/wire,internal/platform/pg/pgtest,services/server/api/operations.yaml,services/server/migrations/. - Dependencies: the header names only M2-02. Everything M2-02 depends on (M1-69 and its closure, among them M1-28, M1-35, M1-49, M1-51, M1-53, M1-54, M1-55, M1-65 and M1-71) is implied. Design note 0 checks M1-55 (thread paths in
StructureSource).
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, permission 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:
- Both operations use the action
channel.project.bind: Permissionowner, Owneryes, Step-upyes, Audityes, Permissionsno, rate-limit classrl.admin(05 4.5, openapix-authz-action). Only owners set a project binding (R-AZ-10); step-up actions only by humans in a session (R-AZ-03, 05 2.4); owner permissions never through a personal API token (R-AZ-14). - A binding exists only for a channel with
kind = 'forum', at most one per forum (PKchannel_id);workspace_refis opaque, unique case-insensitively and never an absolute path of the server;repository_urlis display only and the server never fetches it;runner_configfollows the schemaproject_runner_config.v1and holds no secrets (03project_bindings, principles 8 and 9; 09 18.1). readystarts asfalse; the runner reports readiness in its heartbeat and the server setsproject_bindings.ready(03project_bindings.ready; 09 4.8 rowprojectsand 18.1; openapiHeartbeat.project_readiness).- In a forum with a binding,
agent.invokerequiresproject_bindings.ready = true, otherwiseforbiddenwith reasonproject_not_ready; SR3 applies on top, so until V2 only owners trigger there and non-owners getdev_environment_not_ready(05 section 5 point 7; 07 4.1 and SR3; 09 3.5; 21 section 1). - The binding never appears in events. A change raises
channels.settings_versionand is announced bychannel.settings_changed(receive actionchannel.read) andagent.config_changed(receive actionagent.config.read, audience the agent), both carrying onlychannel_idandsettings_versionand noresource.version(04 2.2 payload rule 1 and 9 point 3; 05 section 6; 09 4.4; 03channels.settings_version). - Last write wins:
If-Matchis optional onputProjectBindingand checked when present (412 version_conflictwithcurrent); theETagis"<version>"; every change raisesversionby one (06 8 points 1 and 3; 03 principle 10). - PUT and DELETE are idempotent by HTTP semantics and take no
Idempotency-Key; a resource without tombstone answers404after its deletion (06 7 row "PUT and DELETE"). - No read permission gives
404, identical to an unknown ID;403only with read permission (05 rule 1.2, R-AZ-01). Archived forums and forums of archived spaces are frozen (07 4.5). - The mandatory reason has no control or bidi characters and at most
audit.reason_max_charscharacters; it is the only free text in the audit details (SEC-AUD-5, SEC-IN-4). Unknown request members give400 validation_failed(SEC-IN-1, 06 2 "Unknown values"). - The binding is readable with
managethroughchannel.behavior.read(ChannelBehavior.project_binding) and by the serving agent throughagent.config.read(05 4.5 and 4.10; R-AZ-09; 02 4.6).
Scope¶
All paths under services/server/ unless they start with docs/. This ticket creates no new top-level Makefile target.
Create:
migrations/<next number>_chat_project_bindings.sql(goose, numbering as the existing files): tableproject_bindingsexactly as 03 defines it (columns, defaults, PKchannel_id, FKchannel_id -> channels(id) ON DELETE CASCADE, FKcreated_by -> principals(id) ON DELETE RESTRICT, unique index onlower(workspace_ref)), privileges for the application role following the pattern of the existing migrations (grep -n GRANT migrations/*.sql), and a Down section. The rule "onlykind = 'forum'" cannot be a CHECK across tables; the use case enforces it (Design note 3) and a comment in the file says so.internal/chat/domain/project_binding.go: the value typesProjectBinding,RunnerConfig,ProjectBindingInput, the pure validationValidateProjectBinding(in ProjectBindingInput, limits BindingLimits) []FieldError(Design note 4; use the field error type the chat domain already returns for400 validation_failed) and the pure change rulePlanBindingPut(current *ProjectBinding, in ProjectBindingInput) BindingChangereturning the kind (created,replaced,unchanged), the next row and whetherreadyis reset (Design note 5).internal/chat/app/project_binding.go: the use casesPutProjectBinding,DeleteProjectBindingandApplyProjectReadiness.internal/chat/adapters/postgres/queries/project_bindings.sql: lock, read, insert, update and delete of a binding, the readiness update, the readiness reset for a set of channels, and the project facts forStructureSource; regenerate the sqlc output.- Tests named in "Tests" below, including
test/integration/project_binding_test.go(cross-module, all real modules).
Change:
internal/chat(port): a new interfaceProjectBindingswith DTOs and doc comments (pseudo-Go; follow the conventions already used ininternal/chat; the actor comes fromkernel/actorin the context):
// ProjectBindings binds forums to development projects (05 `channel.project.bind`) and takes the
// readiness a runner reports (09 4.8). All methods join an ambient unit of work when one exists.
type ProjectBindings interface {
PutProjectBinding(ctx context.Context, cmd PutProjectBindingCmd) (ProjectBinding, error)
DeleteProjectBinding(ctx context.Context, channelID ids.ID) error
ApplyProjectReadiness(ctx context.Context, agentID ids.ID, reports []ProjectReadiness) error
}
type PutProjectBindingCmd struct {
ChannelID ids.ID
WorkspaceRef string
RepositoryURL *string // absent member: nil
DefaultBranch *string
RunnerConfig RunnerConfig // absent member: empty config
Reason string
ExpectedVersion *int64 // from If-Match; nil when the header is absent
}
type RunnerConfig struct { Model *string; AllowedTools []string }
type ProjectReadiness struct { ChannelID ids.ID; Ready bool } // openapi `ProjectReadiness`
- The chat adapter that implements
authz.StructureSource(find it withgrep -rn "StructureSource" internal/chat): fillChannelFacts.ProjectBoundandChannelFacts.ProjectReadyfromproject_bindingsin every method that returns channel facts (Design note 8). - The chat operator use cases of M1-54 (
SetChannelOperator,SetSpaceOperator): reset readiness of bound forums whose effective operator changes (Design note 7). - The behaviour and agent configuration read models of M2-02 (
getChannelBehavior,putChannelBehavior,listServedChannels,getServedChannelConfig): addproject_bindingwhen the forum has a binding (Design note 10). internal/chat/adapters/httpapi: strict-server methods forputProjectBindinganddeleteProjectBinding, including theETagof the200answer.api/operations.yaml: moveputProjectBindinganddeleteProjectBindingfromlatertoenabled(sorted); regenerateapi/restandapi/routeswith the generator documented inapi/README.md.internal/wire: wire theProjectBindingsimplementation and the HTTP handlers; register the configuration key of Design note 4 next tochannel.name_max_chars(find it withgrep -rn "channel.name_max_chars" internal), withChapternaming09-agents.md(add a constantchapter09tointernal/wire/configchapters.goif it does not exist). No module consumesApplyProjectReadinessyet; M2-20 wiresagentsto it.docs/api/openapi.yaml(spec first, Design note 11): new schemaProjectRunnerConfig, referenced fromProjectBinding.runner_configandProjectBindingPut.runner_config;minLength: 1onProjectBindingPut.reason; response'409'(Conflict) ondeleteProjectBinding; descriptions of both operations state the rules of Design notes 3, 5 and 6. Rerunmake api-lint,make api-checksandmake codegen-check.docs/spec/09-agents.md: 18.1 definesproject_runner_config.v1(Design note 11); 21 gets the row of the new keyproject.binding_text_max_chars; regeneratedocs/operations/configuration.mdwithmake config-reference.services/server/test/canaryandservices/server/test/harness(M1-69 and M1-71, in the closure through M2-02; Design note 13).tools/lint/sql_visibility.yaml, only if M1-70 is merged (Design note 12).- Documentation:
internal/chat/README.md(portProjectBindings, tableproject_bindings, actionchannel.project.bind, readiness and its reset, the query exceptions),services/server/CHANGELOG.md(Unreleased),docs/DECISIONS.mdsection 10 rowsD-M2-03-1toD-M2-03-8(Design note 15).
Out of scope¶
- The heartbeat operation
sendHeartbeat, presence and the mapping ofHeartbeat.project_readinessontoApplyProjectReadiness(M2-20); this ticket provides and tests the port. - The trigger decision as a pure function and its rejection table (M2-04), runs and
trigger_statewith reasonproject_not_readyon a message (M2-05), the re-check before every delivery and the run reasonproject_not_ready(M2-08), the problem code403 agent_invocation_deniedof triggering REST operations (M2-09renewInvocationToken, M2-15 interactions, M2-16 commands). This ticket makesauthorize(agent.invoke)decide on readiness. - AZ-M19 end to end with invocation token and delivery (M2-08); AZ-M15 to AZ-M17, dev environments,
DevEnvironmentReadyanddev_environment_id(V2-01, V2-02). - Channel behaviour, prompt, skills, mention rules, their operations and the two event types themselves (M2-02); this ticket adds the binding to them.
- Resolving
workspace_refunderrunner.projects_root, detecting readiness, applyingrunner_configto the command line (M2-34); setting operators (M1-54) apart from the readiness reset of Design note 7. kernel/personaldata.Participantforproject_bindings.created_by(M7, P-14); client and console views of the binding (no plan row yet, see the report).- Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M2-03-project-binding; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(chat): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - M2-02 was briefed in parallel with this ticket. Where its merged code names the behaviour read model, the served-channel queries or the helper that appends
channel.settings_changedandagent.config_changeddifferently from this brief, follow the code and list each difference in the report. - No real agent runtime, runner, coding agent or model call in any test: readiness enters only through
ApplyProjectReadiness, called by the test the way M2-20 will call it. - The server never opens
repository_url(03); no test resolves or contacts it. Example values useexample.comhosts only.
Design notes¶
-
Start check. Run
make checkon the fresh worktree before changing anything; if it is red, stop withVerdict: BLOCKED base branch redand the failing gate. Confirm that M2-02 is merged (getChannelBehavioris inenabledofapi/operations.yaml) and that the chatStructureSourceadapter resolves thread paths (M1-55,grep -rn "func .*Thread(" internal/chat/adapters); otherwise stop withVerdict: BLOCKED dependency <ticket> missing. -
Authorization. Both operations build the descriptor
{kind: channel, id: channel_id}and callauthorize(actor, channel.project.bind, descriptor)before any other check except the schema validation of the request chain (M1-18StageValidateruns before the handler).authzdecides (M1-28): unknown channel or no read permission givesnot_found(404, same body and headers as an unknown ID); read permission without being an effective owner givesforbiddenwith reasonowner_only(403); an agent operating the forum is visible through the operator rule and also gets403 forbiddenwithreason: owner_only; an owner's personal API token has no owner permissions (SR5, R-AZ-14) and gets404without a grant on the forum or403 forbiddenwithreason: owner_onlywith one; an owner whose step-up is older thanauth.step_up_max_agegets401 step_up_required. Denials are audited by the authz layer through the deny aggregator (R-AZ-04, AZ-P13); nothing else in this ticket writes a denial. -
Order of checks for
putProjectBinding(fixed, so tests are deterministic): a. Load the channel (unknown gives404), thenauthorize(note 1). b.kindis notforum:400 validation_failed, field error pointer#path/channel_id, codenot_a_forum(the code M1-53 uses for forum tags; pointer form from 06 5 point 3). c. Body validation of note 4:400 validation_failedwith every field error at once. d. Open the unit of work. Lock thechannelsrowFOR UPDATE; the channel or its space archived gives409 conflict(07 4.5, ruling D2 of M1-53). e. Lock theproject_bindingsrowFOR UPDATEwhen it exists.If-Matchpresent and no row:412 version_conflictwithoutcurrent(RFC 9110: the condition is false without a current representation).If-Matchpresent and not equal toversion:412 version_conflictwithcurrent= the storedProjectBinding. Use theIf-Matchhelpers of M1-18. f. Apply note 5. A unique violation of the index onlower(workspace_ref)(SQLSTATE23505on exactly that index) gives409 conflictwithoutreason; any other database error stays an internal error. g. On a change: raisechannels.settings_versionby one in the locked row (neverchannels.version), append the events of note 9, record the audit of note 6. Answer200with theProjectBindingafter the change andETag: "<version>". Lock order across this ticket (03 6.3 point 2):spaces, thenchannels, thenproject_bindings; several rows of one table in ascending id. Deadlocks and lock timeouts are retried by the unit of work (database.tx_retry_max). -
deleteProjectBinding: steps a, b (400for a non-forum) and d as in note 2; no binding gives404 not_found(06 7: a resource without tombstone answers404after its deletion, so a repeated DELETE answers404); otherwise delete the row, raisechannels.settings_version, append the events of note 9, record the audit of note 6 and answer204. The forum stays a forum; from the commit on,agent.invokethere no longer checks readiness (05 section 5 point 7 applies only to bound forums). -
Validation (pure,
domain.ValidateProjectBinding; field error codes from the vocabulary of M1-18 plusinvalid_charactersof M1-53; the shared validatorkernel/textcheckof M1-26 rejects C0 and C1 control characters and bidi controls, SEC-IN-4, SEC-T07):- New configuration key
project.binding_text_max_chars(Server, positive integer): the maximum length in Unicode characters ofworkspace_ref,repository_url,default_branch,runner_config.modeland each entry ofrunner_config.allowed_tools; longer givestoo_long. Choose the default so that aworkspace_refof that many characters in UTF-8 stays inside the PostgreSQL B-tree entry limit of the unique index onlower(workspace_ref), and write that reason into the row of 09 section 21. Without this key an over-longworkspace_refwould reach the index and end as an internal error. Ruling for docs/DECISIONS.md. workspace_ref(/workspace_ref): not empty (required); no control or bidi characters (invalid_characters);invalid_formatwhen it has leading or trailing white space, starts with/,\,~or-, starts with a drive letter and colon (C:), or has a path segment..when split at/and\. Reason: 03 says "never an absolute path of the server"; the runner still confines the resolved path underrunner.projects_rootincluding symbolic links (09 18.1), this check is defense in depth. Relative refs with/inside (for exampleteam/website) stay allowed.repository_url(/repository_url, optional): an absolute URL with schemehttpsorhttp(the link schemes clients render, SEC-OUT-1) and a host, without user information (no credentials in the database, 03 principle 9), elseinvalid_format; no control or bidi characters.default_branch(/default_branch, optional):invalid_formatwhen empty, when it contains white space or.., or starts with-; no control or bidi characters.runner_config(/runner_config): schemaProjectRunnerConfig(note 11); unknown members giveunknown_memberfrom the request validator.modeland eachallowed_toolsentry: not empty and not starting with-(invalid_format; the runner passes them as process arguments, 09 18.2), no control or bidi characters; duplicate tools are rejected byuniqueItems.reason(/reason): not empty after trimming (required), at mostaudit.reason_max_charscharacters (too_long), no control or bidi characters (SEC-AUD-5).- The whole body stays within
http.max_json_body_bytes(M1-18 answers413otherwise).
- New configuration key
-
PUT semantics (pure,
domain.PlanBindingPut):- No row: insert with
ready = false,version = 1,created_by= actor,created_at = now(); kindcreated. - Row exists: the PUT replaces the whole binding (HTTP PUT): an absent optional member clears the column (
repository_urlanddefault_branchNULL,runner_config{});created_byandcreated_atstay. If the stored values equal the new ones (runner_configcompared asjsonb, so the order ofallowed_toolscounts): kindunchanged, no write, no version change, no event,readyuntouched; answer200with the stored binding and write the audit allow entry withop = unchanged(every allowed audited action is recorded, as M1-54 does for a no-op). Reason: a client that retries a lost PUT must not reset readiness. - Otherwise kind
replaced: write the new values,version + 1, and setready = false. Reason: the runner reported readiness for the old configuration; 05 section 5 point 7 must fail closed until the runner, told byagent.config_changed, reports the new workspace ready. Ruling for docs/DECISIONS.md.
- No row: insert with
-
Audit, in the same transaction through
auditing.Recorder: actionchannel.project.bind, resource the channel, details exactlychannel_id,op(created,replaced,unchanged,deleted),version(after the change; absent fordeleted),ready_reset(boolean, PUT only) andreason(PUT only; DELETE has no body). Neverworkspace_ref,repository_url,default_branchorrunner_config: SEC-AUD-5 allows no other free text, and these are names and configuration, not codes. A404of DELETE without a binding writes no audit entry. -
Readiness.
ApplyProjectReadiness(ctx, agentID, reports)is the only writer ofreadybesides the resets of this note. It runs in the ambient unit of work of the caller (M2-20's heartbeat) or opens its own. A channel id that occurs twice inreportsreturns anerrsvalidation error with pointer/project_readiness(M2-20 maps it to400 validation_failed) and changes nothing.- Lock, in ascending channel id: the
spacesrows of reported channels that inherit their operatorFOR SHARE, thechannelsrowsFOR SHARE, theproject_bindingsrowsFOR UPDATE; compute the effective operator only after these locks (03channelsformula). Reason:setChannelOperatorandsetSpaceOperatorupdate these rows, so the share locks serialize a report against an operator change. - A report is applied only when the channel has a binding, the channel and its space are not archived, and the effective operator of the channel equals
agentID(09 4.8: "runner only"; 09 18.1). Every other entry is ignored without error, so the result never tells an agent anything about forums it does not serve. An applied report that changesreadyraisesproject_bindings.versionby one; an equal value writes nothing. - A readiness change writes no event, no audit entry and does not raise
channels.settings_version. Reason: readiness is runtime state of the runner, not configuration; 04 2.2 forbids its content in events anyway; asettings_versionchange on each flip would make every pendingputChannelBehaviorfail with412 version_conflictand change the ETag ofgetServedChannelConfigwithout a configuration change;agent.heartbeataudits only changes of the reported mode and the tool class (05 4.10). Owners seereadyon their nextgetChannelBehavior. Ruling for docs/DECISIONS.md. - Reset on operator change: in
SetChannelOperatorandSetSpaceOperator(M1-54), after the operator columns are written and inside the same transaction, setready = falseandversion = version + 1for every bound forum whose effective operator changed and whosereadyistrue(for a space: its inheriting, non-archived channels). Reason: readiness was reported by an agent that no longer serves the forum. No extra event: M1-54 already writeschannel.updatedandaccess.changed. Ruling for docs/DECISIONS.md.
-
agent.invokefacts. The chatStructureSourceadapter fillsChannelFacts.ProjectBound(aproject_bindingsrow exists) andChannelFacts.ProjectReady(itsready) with aLEFT JOINin every query that returns channel facts, reading in the ambient transaction when one exists. The guard order stays as M1-28 built it (step 6 after SR2): bound and not ready givesproject_not_ready; bound, ready, principal not an effective owner andDevEnvironmentReadyfalse givesdev_environment_not_ready(SR3 rule 1;DevEnvironmentReadystays false until V2). Owners trigger a ready bound forum. Nothing in this ticket raisespermission_versionor writesaccess.changed(05: Permissionsno): structure facts are read fresh per call in REST mode (M1-28 "Resolution"). The delivery mode of M1-65 (authorize.Delivery, in the closure through M2-02), whose cached resolved paths are invalidated only by structure events, is ininternal/authz: make theagent.invokefact gathering read the channel facts fresh in that mode as well, because readiness changes write no event (note 7); 07 SR2 states the reason: readiness changes without a permission transaction, and the invocation is the only place where all facts are current. IfChannelFactslacks the two fields in the merged code, add them to the interface ininternal/authzand to its fakes. -
Events on
created,replacedanddeleted(throughkernel/eventlog.Appender, in this order, the way M2-02 appends them forputChannelBehavior):channel.settings_changed: scope{space_id, channel_id}, resource{kind: channel, id: channel_id}withoutversion, payloadChannelSettingsChangedPayload {channel_id, settings_version}, receive actionchannel.read.agent.config_changed: same scope, resource and payload (AgentConfigChangedPayload), receive actionagent.config.read, audience the forum's effective operator (event_log.audience_principal_id). When the forum has no effective operator, append noagent.config_changed, because no receiver exists; if M2-02 handles this case differently, follow M2-02 and report it.- No JSON
null(ADR 0053); no binding value anywhere in the envelope or payload (04 2.2 rule 1).
-
Read models.
ChannelBehavior.project_binding(in the answers ofgetChannelBehaviorandputChannelBehavior) andAgentChannelConfig.behavior.project_binding(inlistServedChannelsandgetServedChannelConfig) carry theProjectBindingexactly when the forum has a binding; otherwise the member is absent, nevernull. The permissions stay those of M2-02 (managefor behaviour; the serving agent for its config). The ETag of these operations issettings_version, which notes 2 and 3 raise. -
Schema
project_runner_config.v1(03 and openapi name it; no file defines it yet). Define it in 09 18.1 and asProjectRunnerConfigindocs/api/openapi.yaml: an object withadditionalProperties: false(required for every object reachable from a request body, W7 of M1-18; the M1-17 generator check fails on an open one) and the optional membersmodel(string,minLength: 1) andallowed_tools(array of strings withminLength: 1,uniqueItems: true), description "Schemaproject_runner_config.v1. No secrets." The server checks only this shape and the rules of note 4; it does not interpret tool names. The runner never widens its configured tool class with this list: it derives its flags from the class and narrows them by the list (09 18.2; M2-34 builds that). The 03 rule "withinagents.tool_class" is therefore enforced by the runner. Ruling for docs/DECISIONS.md. -
SQL visibility lint. If
tools/lint/sql_visibility.yamlexists (M1-70), every new read ofproject_bindingseither carries a predicatechannel_id = ANY(<param>)or gets an exception entry: theStructureSourcefacts with categoryauthorize_resolution, the binding read afterauthorizein PUT, DELETE and the read models with categoryafter_authorize; each with a reason. The readiness lock query filterschannel_id = ANY(<reported ids>). Writes are outside the lint (07 7.4 rule 1). -
Canary scenario.
services/server/test/canaryexists (M1-69, in the closure through M2-02), and its testTestSEC_T10_ScenarioCoversEveryEnabledOperationfails for the two new operations: add both to the scenario with canary values inworkspace_ref, the path ofrepository_url(hostgit.example.com),default_branch,runner_config.modelandreason. Allowed sinks: the200answer of the PUT,getChannelBehavior,getServedChannelConfigandlistServedChannelsanswers, and the auditreason(the only free text SEC-AUD-5 allows); everything else (logs, traces, metrics, event log, other audit fields, error bodies) must stay free of them. -
Concurrency. Two PUTs on one forum serialize on the
channelsrow. Two PUTs with the sameworkspace_refon different forums: the unique index lets exactly one commit, the other gets409 conflict. A PUT and a readiness report on the same forum serialize on theproject_bindingsrow (the PUT holds thechannelsrowFOR UPDATE, the report takes itFOR SHAREfirst, so the lock order is the same). -
Rulings for
docs/DECISIONS.mdsection 10 (one row each with date, decision, reason, location):D-M2-03-1schemaproject_runner_config.v1(note 11);D-M2-03-2configuration keyproject.binding_text_max_chars(note 4);D-M2-03-3format rules ofworkspace_ref,repository_url,default_branchand the runner config strings (note 4);D-M2-03-4PUT replaces the whole binding, an identical PUT is a no-op that keepsready(note 5);D-M2-03-5every effective PUT change and every change of the effective operator resetsready(notes 5 and 7);D-M2-03-6readiness reports only from the effective operator of a bound, non-archived forum, other entries ignored, no event, no audit, nosettings_versionchange (note 7);D-M2-03-7status codes: non-forum400withnot_a_forum, archived409 conflict, DELETE without binding404,If-Matchwithout binding412(notes 2 and 3);D-M2-03-8noagent.config_changedwithout an effective operator (note 9), unless M2-02 decided otherwise.
Tests (write them first)¶
Go test names carry the specification ID where one exists, so go test -run AZ_P1 finds it.
Unit (internal/chat/domain, table tests; limits read from the configuration in the test, never hard-coded):
TestValidateProjectBinding_WorkspaceRef: acceptedwebsiteandteam/website; rejected with the code of note 4: empty, white space only, leading or trailing space,/srv/website,\srv\website,C:\srv\website,~website,-website,team/../website,..\website, a value one character overproject.binding_text_max_chars(too_long), one at exactly the limit accepted.TestSEC_T07_ProjectBindingRejectsControlAndBidi: every code point listed bykernel/textcheckis rejected inworkspace_ref,repository_url,default_branch,runner_config.model, anallowed_toolsentry andreason, each with codeinvalid_charactersand the right pointer.TestValidateProjectBinding_RepositoryURL:https://git.example.com/team/websiteandhttp://git.example.com/team/websiteaccepted;ftp://git.example.com/x,javascript:alert(1),git.example.com/team/website(no scheme),https://alex@git.example.com/x(user information) andhttps:///x(no host) rejected withinvalid_format.TestValidateProjectBinding_DefaultBranchAndRunnerConfig:mainandrelease/2026accepted; empty,my branch,a..b,-mainrejected;modelempty or-xrejected;allowed_toolsentry-rfrejected.TestValidateProjectBinding_Reason: blank givesrequired;audit.reason_max_charsplus one givestoo_long.TestPlanBindingPut: no row givescreatedwithreadyfalse and version 1; equal values giveunchangedand keepreadytrue; any changed member (including only the order ofallowed_tools, or clearingrepository_urlby omitting it) givesreplaced, version plus one andready_resettrue;created_byandcreated_atkept.
Unit (internal/authz): the existing TestAgentInvoke_* cases of M1-28 for "project not ready" and "dev environment not ready" stay green unchanged; if note 8 adds the fields or a delivery-mode re-read, add a table case per change.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain with the response validator, people and sessions from identitytest; world of 07 "Examples" from the M1-54 world builder in test/integration/, extended additively where missing: owner, Alex with trigger and manage on space Engineering, Sam without any grant on Engineering (as in the world of 07), Robin with trigger on the forum Engineering/Projekt-A; agents Runner (organization, fixed operator of the forum Engineering/Projekt-A) and Lotse (operator of Support); a second forum Engineering/Projekt-B with Runner as operator; a text channel Engineering/Werkzeuge; every response checked against docs/api/openapi.yaml):
TestPutProjectBinding_OwnerOnlyWithStepUp: owner with fresh step-up gives200withreadyfalse,version1 andETag: "1"; owner with step-up older thanauth.step_up_max_agegives401 step_up_required; Alex (read,manage) gives403 forbiddenwithreason: owner_only; Sam (no read on Engineering) gives404with status, body and headers equal to those for a random channel id after removingcorrelation_idandinstance; Runner's agent token gives403 forbiddenwithreason: owner_only; the owner's personal API token without grants gives404. Rows unchanged after every denial.TestDeleteProjectBinding_OwnerOnlyWithStepUp: the same principals against DELETE with the same statuses; the owner gets204.TestAZ_P13_ProjectBindingDenialsAudited: after flushing the deny aggregator as the M1-24 tests do, the denials of Alex, Sam and the stale step-up stand inaudit_eventswithdecision = denyand reasonsowner_only,no_grantandstep_up_required.TestPutProjectBinding_CreateReplaceNoOp: create, then an identical PUT gives200with the sameversion, no newevent_logrow and an audit entry withop = unchanged; afterApplyProjectReadinesshas setreadyto true, the identical PUT keeps it true; a PUT that omitsrepository_urlclears it (absent in the answer, nonull), raisesversionby one and setsreadyfalse.TestPutProjectBinding_NotAForum: PUT and DELETE on Engineering/Werkzeuge give400 validation_failedwith pointer#path/channel_idand codenot_a_forum.TestPutProjectBinding_ArchivedForum409: afterarchiveChannelon Projekt-B, PUT and DELETE give409 conflict; same for a forum of an archived space.TestPutProjectBinding_WorkspaceRefTakenCaseInsensitive409:Websiteon Projekt-B afterwebsiteon Projekt-A gives409 conflict; after deleting the binding of Projekt-A it succeeds.TestPutProjectBinding_ConcurrentSameRef: two PUTs of the sameworkspace_refon Projekt-A and Projekt-B behind a barrier: exactly one200, one409 conflict, one row.TestPutProjectBinding_IfMatch: withoutIf-Match200; with the current version200; with an old version412 version_conflictwhosecurrentvalidates asProjectBinding;If-Matchon a forum without binding412withoutcurrent.TestPutProjectBinding_UnknownRunnerConfigMember:runner_config: {"api_key": "x"}gives400 validation_failedwith pointer/runner_config/api_keyand codeunknown_member; nothing stored.TestDeleteProjectBinding_Twice: first204, row gone; second404 not_found, no event, no audit entry.TestProjectBinding_EventsAndSettingsVersion: a create raiseschannels.settings_versionby one and leaveschannels.versionunchanged; exactlychannel.settings_changedthenagent.config_changedwith consecutiveseq, both with resource{kind: channel, id}withoutversion, payload{channel_id, settings_version}, receive actionschannel.readandagent.config.read,audience_principal_id= Runner on the second; both validate against their schemas; no JSONnull; the canary strings ofworkspace_ref,repository_url,default_branchandrunner_config.modelappear in noevent_logrow and in no audit detail (the only free text there is thereason). Same for a replace and a delete. A forum the test creates with operator modenoneand no space default gets onlychannel.settings_changed.TestProjectBinding_AuditDetails: the allow entry of a create has exactly the keyschannel_id,op,version,ready_reset,reason; a delete haschannel_idandop.TestProjectBinding_ReadModels: owner and Alex (manage) seeproject_bindingingetChannelBehavior; Runner sees it ingetServedChannelConfigand in itslistServedChannelsitem for Projekt-A; Projekt-B without binding has noproject_bindingmember; theETagofgetChannelBehaviorchanges after a PUT.TestAgentInvoke_ProjectNotReady(05 section 5 point 7; theauthorizepart of AZ-M19): a forum thread of Projekt-A created throughchat.Threads.Createwith operator Runner;authorize(owner, agent.invoke, {thread, Invoke{AgentID: Runner, TriggerOrigin: "human"}})isforbiddenwith reasonproject_not_ready; afterApplyProjectReadiness(Runner, [{Projekt-A, true}])it isallowandpermission_versionis unchanged; after a PUT that changesdefault_branchit isproject_not_readyagain; after DELETE it isallowwithout any readiness report.TestAgentInvoke_ProjectBindingNonOwner: Robin (triggeron Projekt-A), with an operator of tool classchatso that SR2 does not apply (Runner keeps the defaultchatunless the world builder raised it; then the test uses an organization agent with tool classchatas fixed operator of a forum it creates): not ready givesproject_not_ready; ready givesdev_environment_not_ready(SR3; 21 section 1).TestApplyProjectReadiness_OnlyEffectiveOperator: a report by Lotse for Projekt-A changes nothing; a report by Runner setsready, raisesproject_bindings.versionby one, leaveschannels.settings_versionunchanged and writes noevent_logrow and no audit entry; repeating it writes nothing; reports for an unbound forum, an archived bound forum and a random id are ignored without error.TestApplyProjectReadiness_DuplicateChannelRejected: the same channel twice with different values returns a validation error and changes nothing.TestOperatorChangeResetsReadiness: ready binding on Projekt-A;setChannelOperatorto another organization agent setsreadyfalse and raisesversion; a later report by Runner is ignored and one by the new operator applies. Same throughsetSpaceOperatorfor a bound forum that inherits the space default.TestApplyProjectReadiness_ConcurrentOperatorChange: a readiness report by Runner andsetChannelOperatorto another agent behind a barrier, repeated with both orders: no deadlock reaches the caller, and afterwardsreadyistrueonly if the effective operator is Runner.TestAZ_P1_RouteCount: the route count test passes with the two new operations; both carrychannel.project.bindandrl.admin.
Contract and generators: make api-lint and make api-checks accept the changed openapi.yaml; make codegen-check shows no drift; the M1-17 check "request schemas closed" passes with ProjectRunnerConfig; make config-doc-check shows project.binding_text_max_chars in the reference.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/chat/... ./internal/authz/... ./internal/kernel/... ./internal/wire/... ./migrations/... ./test/integration/...make migrations-lint,make codegen-check,make arch,make api-lint,make api-checks,make register-check,make config-doc-check,make docsmake check
Acceptance criteria¶
-
putProjectBindinganddeleteProjectBindingare enabled and registered withchannel.project.bindandrl.admin; AZ-P1 passes. - Only an owner in a session with fresh step-up changes a binding: non-owner with read
403 forbiddenwithreason: owner_only, without read404identical to an unknown id, stale step-up401 step_up_required, agent and personal tokens never succeed; allows and denials are audited (AZ-P13). - A binding exists only on non-archived forums,
workspace_refis unique case-insensitively, and the validation rules of note 4 hold, including the closedproject_runner_config.v1. -
authorize(agent.invoke)in a bound forum givesproject_not_readywhilereadyis false andallowfor an owner once the serving runner reported it ready, without a newpermission_version; non-owners getdev_environment_not_ready. -
readyis written only byApplyProjectReadinessfrom the forum's effective operator and is reset by every effective PUT change and every change of the effective operator. - Every binding change raises
channels.settings_versionand appendschannel.settings_changedandagent.config_changedwith onlychannel_idandsettings_version; no binding value appears in events or in the audit details apart from the reason. -
getChannelBehavior,putChannelBehavior,listServedChannelsandgetServedChannelConfigcarryproject_bindingexactly for bound forums. - 09 18.1 defines
project_runner_config.v1, 09 21 and the configuration reference listproject.binding_text_max_chars,openapi.yamlhasProjectRunnerConfigand409ondeleteProjectBinding, anddocs/DECISIONS.mdhas the rowsD-M2-03-1toD-M2-03-8. -
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
chatand, if note 8 changed it,authzupdated (portProjectBindings, tableproject_bindings, readiness and its reset, the query exceptions of note 12). - Rulings of note 15 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>.