<!-- Authored per the the-loop:writing skill. -->
Design: an operator can bring their own graphs and bind commands to them
Phase 2 of 4 (requirements → design → testing plan → tasks). Derives from
requirements.md. MUST be reviewed and approved before moving to test planning.
Overview
One new module (graph/catalog.py) parses the top-level graphs and routing.control.commands; the compiler, the loader and the one fail-closed resolver learn a second source of names; the control parser learns the declared words and records the loop each one selects. A new command is start with a loop attached — so every seam that already handles start (authorization, spawn policy, arming, disarming) handles it unchanged.
§1 The declaration — top-level graphs, routing.control.commands
Two declarations, kept apart (the owner's shape, PR #425 review): which graphs exist and which command selects which graph.
graphs:
- name: acme-triage-loop # ^[a-z][a-z0-9-]*$, not pdlc-*, not shipped
path: graphs/acme-triage-loop.yaml # absolute, ~/…, or relative to this file's dir
- name: acme-quick-loop
path: ~/.the-loop/graphs/quick.yaml
- name: acme-guest-review
path: /etc/the-loop/review.yaml
guest: true # keeps the guest posture (R5)
routing:
control:
commands:
triage: {graph: acme-triage-loop} # a NEW command → `the-loop triage`
do: {graph: acme-quick-loop} # RE-POINTS the shipped `do`
review: {graph: acme-guest-review}A command with no entry keeps its shipped loop, so nothing needs writing to keep today's behaviour. The loader fans the top-level list into routing._graphs (cli_config.apply_graphs, called beside apply_instance): the dispatcher and the other ControlConfig builders are handed the routing block alone, exactly as instance travels as _instance.
graph/catalog.py owns both:
| Name | Shape | Notes |
|---|---|---|
CATALOG_KEY, BINDINGS_KEY | "graphs", "routing.control.commands" | For messages |
RESERVED_PREFIX | "pdlc-" | R1.2 |
CustomGraph | frozen: name, path (raw), guest: bool | resolved_path(base) expands ~, keeps absolute, joins relative onto base |
Catalog | frozen: entries: Tuple[CustomGraph,…] | names, get(name), is_guest(name), empty, digest() |
read_catalog(cli_config) / parse_catalog(raw) | → Catalog | Top-level graphs, else routing._graphs; validates R1.1–R1.5; absent → empty |
Binding | frozen: word, graph, keyword | One routing.control.commands entry |
read_bindings(block, catalog) | → {word: Binding} | Validates R3.1–R3.4 against the catalog; absent → {} |
config_base() | → Path | The directory of the CLI config in effect (--config, $THE_LOOP_CLI_CONFIG, ./.the-loop/, ~/.the-loop/) |
compile_custom(entry, target) | → Graph | Reads, compiles, checks R2.2/R2.3; x- hooks deferred (§3) |
Command-word validation needs the control vocabulary. catalog imports it from the_loop.control inside read_bindings, and control imports the catalog inside ControlConfig.from_mapping: two function-level imports, no module-level cycle, and control keeps its "no graph imports at module level" property.
Rules, all at parse time:
- A graph:
namegrammar, not shipped, notpdlc-*, unique;patha non-empty string;guesta boolean when present; any other key refused (acommandskey gets a hint pointing atrouting.control.commands). - A binding: the word matches
^[a-z][a-z0-9-]*$and is either inSPAWN_COMMANDS(start,contribute,do,review) or in neitherCOMMANDSnor the-loop's own verbs;graphnames a shipped outer-path loop or a declared graph;keywordonly on a new word, a non-empty string, whitespace-normalised. A word cannot be bound twice — it is one key of one mapping. - The schema says the same (
additionalProperties: false,required), enforced in code too so a config that skips schema validation still fails closed.
§2 The compiler and the loader (graph/model.py)
PHASE_VOCABULARY— the eleven phases the shipped loops declare, in order (phase-selection…cleanup). A test pins it equal to the union of the shipped loops' phases, so a new shipped phase must be added here in the same change.Node.commandgrammar —^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)?$or empty, enforced in_build_nodefor every graph (every shipped command already fits).slash_command(command)—"/the-loop:<c>"for a bare name,"/<c>"for a namespaced one. The two renderers (graphlink.render_graph_context,hooks/assignment.py) call it instead of formatting/the-loop:themselves.compile_graph(data, known=None)—knownis threaded to_build_node→_validate_chain, the predicateextensions.applyalready passes for attachments.resolve_outer_loop(name, declared=())— returnsnamewhen it is a non-default shipped outer-path loop or a member ofdeclared;""otherwise. Still the one decision; every reader passes the catalog's names.load_graph(path=None, repo=None, name=…, declaration=None, catalog=None):- A shipped
name→ exactly today's path. - A name the catalog declares →
catalog.compile_custom(entry, config_base()), then §3's extension resolution, thenextensions.applyas for a shipped loop. The cache key is(resolved path, repo, declaration digest), as today. - Anything else →
GraphConfigError("… is neither a shipped loop nor declared in the top-level graphs")— instead of today's misleading "packaging fault". _warn_on_repo_graph(repo, extra=catalog.names)warns for the shipped filenames and the declared names, and the message names the top-levelgraphs(R8).
- A shipped
compile_custom in order: resolve the path; read (an OSError names the graph and the file); yaml.safe_load; require a mapping; if name is present require it equal the declared name, else set it; compile_graph(data, known=is_extension_name); every node's phase in PHASE_VOCABULARY.
§3 x- hooks inside a custom graph
A shipped graph may not name an x- hook (the registry refuses the prefix). A custom one may (R2.4), because the operator authored both. The compile defers them — known accepts any x- name — and load_graph then resolves them:
- collect the
x-names the compiled chains reference; - none → done;
- some, but no
repoor no declared modules →GraphConfigErrornaming the hooks; extensions.load_modules(repo, declaration); any referenced name missing from the table →GraphConfigError; elsereplace(graph, extension_hooks=table).
Graph.hook_for already consults extension_hooks first, so the chain runner needs no change. the-loop graph loops stops after step 1 and lists the names — it never imports a module (R7.2).
§4 Attachment scoping (graph/extensions.py)
Attachment gains loops: Tuple[str, …] = (). read_declaration reads an optional loops list, validating each name against the shipped loops plus the catalog's names (R6.2). apply skips an attachment whose loops is non-empty and does not contain graph.name; an empty loops keeps today's behaviour exactly, including failing on a missing node (R6.1). Declaration.digest() includes loops.
§5 Commands (control.py)
ControlConfiggainsbindings: Dict[str, str](word → custom loop, overrides and new words alike) andloops: Tuple[str, …](every declared custom name).from_mapping(data, graphs=None)fills both fromparse_catalog(graphs)andread_bindings(data["commands"], catalog), and addskeywords[word] = binding.keyword or "the-loop <word>"for every new word, refusing one that equals a configured keyword.ControlResultgainsloop: str = "".parse_commandscansCOMMANDSthen the new words, with the same boundary patterns.foundholds the words, sothe-loop start+the-loop triageis ambiguous (R3.4). A lone new word returnscommand=START, loop=<bound loop>, matched=[word]; a lone spawn command with an override returns that command withloop=<bound loop>.ControlRecordgainsloop: str = ""(serialized as"loop"; absent in older records reads as"").ControlStore.record(..., loop="")writes it.- Every other constant (
COMMANDS,SPAWN_COMMANDS,_ARMING_COMMANDS) is unchanged.
§6 The dispatcher and the graph coupling
dispatcher.py:ControlConfig.from_mapping(data.get("control"), graphs=data.get("_graphs"));_apply_control(control.command, routed, loop=control.loop); itsrecord()passesloop; thecontrol.commandevent carriesloopwhen set (R3.8).review's PR binding keys on the command, so an overriddenreviewstill binds to the PR (R3.7).- The other
ControlConfig.from_mappingbuilders that parse or select (channels/inbound.py,channels/commands.py,core/sessions.py) pass the declared graphs too, so every surface recognises the same vocabulary; the Slack slash command resolves a new word as a verb. (channels/slack.py's buttons list only the built-in keywords and parse the control block without itscommands.) graphlink._outer_loop_name: state first →resolve_outer_loop(recorded, self.control.loops); then the control record →resolve_outer_loop(record.loop, self.control.loops)whenrecord.loopis set, else the shippedLOOP_FOR_CONTROL_COMMAND(unchanged for every existing record). With no usable recorded loop — none recorded, no record, or one naming a graph no longer declared — the command's current binding applies (for no record,start's), else its shipped loop. The control-record branch goes through the resolver too, closing the gap the survey noted. A binding to the default loop itself (do: {graph: pdlc-work-item-loop}) selects the default, never falling through to the command's shipped loop.
§7 The runtime builder and the CLI
bootstrap.build_runtime:catalog = read_catalog(cli_cfg);chosenis kept when it is inOUTER_PATH_LOOPSorcatalog.names, else the default with a warning;guestLoop = chosen in GUEST_LOOPS or catalog.is_guest(chosen);load_graph(..., catalog=catalog). A catalog that fails to parse raises — the same postureread_declarationhas.core/graphs._recorded_loop: resolves against the best-effort CLI config's catalog.the-loop graph loops [--format text|json](commands/graph_cmd.py): one row per shipped loop and per declared graph, with the keywords of the commands that select it afterrouting.control.commandsis applied (a re-pointed command moves to the row of the graph it now selects), guest, resolved path, statusok/ the compile error, referencedx-hooks). Exit 1 when any declared graph fails to compile or the catalog itself fails to parse. Local only, likegraph hooks— it reads the operator's config, which the service API does not expose.
Data models
- CLI config schema (
.the-loop/cli-config.schema.jsonand the packaged copy): top-levelgraphs(array of{name, path, guest?},additionalProperties: false, patterns as §1),routing.control.commands(object of{graph, keyword?},graphrequired), androuting.graph.hooks.attach[].loops(array of strings). - Control record (
portable/<slug>.json,controlsection):loop(string, optional). Additive; readers of older records see"". work-item-state.json: unchanged shape —loopmay now hold a declared custom name.
Error handling
| Failure | Where | Surfaced as |
|---|---|---|
Malformed graphs or routing.control.commands | read_catalog / read_bindings (daemon start, build_runtime, graph loops) | GraphConfigError naming the entry; the daemon refuses to start |
| Custom YAML missing / invalid / wrong name / unknown phase / unknown hook | compile_custom / load_graph | GraphConfigError naming the graph and the file; on the daemon path, logged as graph.link_failed (the coupling's existing best-effort envelope) |
| Recorded loop not declared | resolve_outer_loop | "" → default loop; build_runtime logs the ignored name at warning |
x- hook referenced without modules | load_graph | GraphConfigError naming the hooks |
Attachment loops names an unknown loop | read_declaration | GraphConfigError |
Security design
- AuthN/AuthZ: unchanged. A new command is parsed only after the self-marker and
authorizedUsersguards, then re-checked for a named actor on the control path — it reaches_apply_controlasSTART, so no new branch can skip the check. - Input validation & injection surfaces:
- Comment body: the parser returns only declared words (fixed at config load); the
loopin aControlResultis looked up from the operator's bindings by word, never copied from the body. A word shaped like a command but not declared matches nothing. - State file / control record
loop: passed throughresolve_outer_loop(name, declared); only an exact member of the shipped outer-path set or the declared names survives. The value is never used as a path —load_graphlooks the name up in the catalog and uses the catalog's path. - Path: only from the operator's config; no containment rule is imposed, because the operator's filesystem is the operator's (the same stance as
critics[].command).
- Comment body: the parser returns only declared words (fixed at config load); the
- Secrets handling: none involved.
- Least privilege: no new permission; a custom graph can only name hooks that are already registered (shipped) or already declared by the operator (
x-). - Fail-closed behaviour: every parse and compile fault raises; an unresolvable recorded loop reads as the default (the pre-existing rule); a declared graph that fails to load never falls back to the default (R4.4) —
build_runtimeraises and the coupling recordsgraph.link_failed. - Abuse-case coverage:
| Abuse case (requirements) | Mechanism | Negative test |
|---|---|---|
1. forged loop in state | resolve_outer_loop(name, declared) | test_graph_catalog.py::test_forged_state_loop_reads_as_default |
| 2. undeclared keyword | parser scans declared words only | test_control_custom_commands.py::test_undeclared_word_is_no_command |
| 3. unauthorized new command | existing unauthorized-actor path (command is START) | test_control_custom_commands.py::test_unauthorized_custom_command_is_refused |
| 4. new + built-in command | found holds words → ambiguous | test_control_custom_commands.py::test_custom_and_builtin_command_is_ambiguous |
| 5. repo file shadowing a declared name | loader never reads the repo; warning | test_graph_catalog.py::test_repo_file_for_declared_name_is_ignored |
6. rebinding stop etc., pdlc-* names, a binding to an undeclared graph | read_catalog / read_bindings refuse | test_graph_catalog.py::test_catalog_refuses_*, test_binding_refuses_* |
Testing strategy
Unit tests pin each parser and resolver rule (test_graph_catalog.py, test_control_custom_commands.py, additions to test_graph_extensions.py); an integration test drives the dispatcher end to end — an authorized the-loop triage on a ticket arms, spawns, and the spawned work item's state records the custom loop — with a Gherkin docstring (test_custom_graph_integration.py, Scenario: a new command arms a work item onto the operator's graph). The CLI verb is tested through its command class. The executable detail is testing-plan.md.
Trade-offs & decisions
| Choice | Alternatives | Why |
|---|---|---|
| Declared in the CLI config | A repository's .the-loop/graphs/ | decision-123: what runs in the-loop's process is the operator's. A graph in the checkout would also be agent-writable — the session could rewrite its own gates |
A new command is start + a loop | New entries in COMMANDS / SPAWN_COMMANDS | Every seam branching on the arming constants keeps working with no edits, and ControlStore needs no config to answer start_requested |
| The loop is recorded on the control record | Re-derived from the command at spawn time | A new word has no constant to derive from; recording also freezes an override against a config edit between arming and spawn |
| Phases restricted to the shipped vocabulary | Free phases | decision-123 D3 — one label vocabulary for every repository; /the-loop:init creates exactly those labels |
Two declarations: top-level graphs + routing.control.commands | One routing.graph.graphs list carrying each graph's commands (this PR's first shape) | The owner's call in the PR #425 review. A graph is a top-level fact like critics[]; the command vocabulary already lives in routing.control; and a command can be re-pointed at another shipped loop with no graph declared |
A new word's keyword defaults to the-loop <word>, overridable by keyword | Derived only | Per-command text is one optional key once commands live in routing.control; a built-in command's keyword stays in routing.control.keywords, so each keyword has one home |
pdlc- reserved | x- prefix required | Graph names are user-facing (they appear in the state file and graph loops); reserving the shipped family keeps them readable |
attach[].loops opt-in | Silently skip attachments whose node is absent | Silently skipping would turn a typo'd node into a check that never runs — the failure issue-248 exists to prevent |
Recorded as decision-136.
Open questions
None.
Review comments
Appended by the-loop's
record-feedbackhook when a human gate approves with comments (issue-109).