Skip to content

<!-- 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.

yaml
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:

NameShapeNotes
CATALOG_KEY, BINDINGS_KEY"graphs", "routing.control.commands"For messages
RESERVED_PREFIX"pdlc-"R1.2
CustomGraphfrozen: name, path (raw), guest: boolresolved_path(base) expands ~, keeps absolute, joins relative onto base
Catalogfrozen: entries: Tuple[CustomGraph,…]names, get(name), is_guest(name), empty, digest()
read_catalog(cli_config) / parse_catalog(raw)→ CatalogTop-level graphs, else routing._graphs; validates R1.1–R1.5; absent → empty
Bindingfrozen: word, graph, keywordOne routing.control.commands entry
read_bindings(block, catalog)→ {word: Binding}Validates R3.1–R3.4 against the catalog; absent → {}
config_base()→ PathThe directory of the CLI config in effect (--config, $THE_LOOP_CLI_CONFIG, ./.the-loop/, ~/.the-loop/)
compile_custom(entry, target)→ GraphReads, 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: name grammar, not shipped, not pdlc-*, unique; path a non-empty string; guest a boolean when present; any other key refused (a commands key gets a hint pointing at routing.control.commands).
  • A binding: the word matches ^[a-z][a-z0-9-]*$ and is either in SPAWN_COMMANDS (start, contribute, do, review) or in neither COMMANDS nor the-loop's own verbs; graph names a shipped outer-path loop or a declared graph; keyword only 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.command grammar — ^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)?$ or empty, enforced in _build_node for 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) — known is threaded to _build_node → _validate_chain, the predicate extensions.apply already passes for attachments.
  • resolve_outer_loop(name, declared=()) — returns name when it is a non-default shipped outer-path loop or a member of declared; "" 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, then extensions.apply as 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-level graphs (R8).

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:

  1. collect the x- names the compiled chains reference;
  2. none → done;
  3. some, but no repo or no declared modules → GraphConfigError naming the hooks;
  4. extensions.load_modules(repo, declaration); any referenced name missing from the table → GraphConfigError; else replace(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) ​

  • ControlConfig gains bindings: Dict[str, str] (word → custom loop, overrides and new words alike) and loops: Tuple[str, …] (every declared custom name). from_mapping(data, graphs=None) fills both from parse_catalog(graphs) and read_bindings(data["commands"], catalog), and adds keywords[word] = binding.keyword or "the-loop <word>" for every new word, refusing one that equals a configured keyword.
  • ControlResult gains loop: str = "".
  • parse_command scans COMMANDS then the new words, with the same boundary patterns. found holds the words, so the-loop start + the-loop triage is ambiguous (R3.4). A lone new word returns command=START, loop=<bound loop>, matched=[word]; a lone spawn command with an override returns that command with loop=<bound loop>.
  • ControlRecord gains loop: 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); its record() passes loop; the control.command event carries loop when set (R3.8). review's PR binding keys on the command, so an overridden review still binds to the PR (R3.7).
  • The other ControlConfig.from_mapping builders 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 its commands.)
  • 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) when record.loop is set, else the shipped LOOP_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); chosen is kept when it is in OUTER_PATH_LOOPS or catalog.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 posture read_declaration has.
  • 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 after routing.control.commands is applied (a re-pointed command moves to the row of the graph it now selects), guest, resolved path, status ok / the compile error, referenced x- hooks). Exit 1 when any declared graph fails to compile or the catalog itself fails to parse. Local only, like graph hooks — it reads the operator's config, which the service API does not expose.

Data models ​

  • CLI config schema (.the-loop/cli-config.schema.json and the packaged copy): top-level graphs (array of {name, path, guest?}, additionalProperties: false, patterns as §1), routing.control.commands (object of {graph, keyword?}, graph required), and routing.graph.hooks.attach[].loops (array of strings).
  • Control record (portable/<slug>.json, control section): loop (string, optional). Additive; readers of older records see "".
  • work-item-state.json: unchanged shape — loop may now hold a declared custom name.

Error handling ​

FailureWhereSurfaced as
Malformed graphs or routing.control.commandsread_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 hookcompile_custom / load_graphGraphConfigError 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 declaredresolve_outer_loop"" → default loop; build_runtime logs the ignored name at warning
x- hook referenced without modulesload_graphGraphConfigError naming the hooks
Attachment loops names an unknown loopread_declarationGraphConfigError

Security design ​

  • AuthN/AuthZ: unchanged. A new command is parsed only after the self-marker and authorizedUsers guards, then re-checked for a named actor on the control path — it reaches _apply_control as START, 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 loop in a ControlResult is 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 through resolve_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_graph looks 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).
  • 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_runtime raises and the coupling records graph.link_failed.
  • Abuse-case coverage:
Abuse case (requirements)MechanismNegative test
1. forged loop in stateresolve_outer_loop(name, declared)test_graph_catalog.py::test_forged_state_loop_reads_as_default
2. undeclared keywordparser scans declared words onlytest_control_custom_commands.py::test_undeclared_word_is_no_command
3. unauthorized new commandexisting unauthorized-actor path (command is START)test_control_custom_commands.py::test_unauthorized_custom_command_is_refused
4. new + built-in commandfound holds words → ambiguoustest_control_custom_commands.py::test_custom_and_builtin_command_is_ambiguous
5. repo file shadowing a declared nameloader never reads the repo; warningtest_graph_catalog.py::test_repo_file_for_declared_name_is_ignored
6. rebinding stop etc., pdlc-* names, a binding to an undeclared graphread_catalog / read_bindings refusetest_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 ​

ChoiceAlternativesWhy
Declared in the CLI configA 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 loopNew entries in COMMANDS / SPAWN_COMMANDSEvery 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 recordRe-derived from the command at spawn timeA 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 vocabularyFree phasesdecision-123 D3 — one label vocabulary for every repository; /the-loop:init creates exactly those labels
Two declarations: top-level graphs + routing.control.commandsOne 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 keywordDerived onlyPer-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- reservedx- prefix requiredGraph names are user-facing (they appear in the state file and graph loops); reserving the shipped family keeps them readable
attach[].loops opt-inSilently skip attachments whose node is absentSilently 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-feedback hook when a human gate approves with comments (issue-109).

Released under the MIT License.