0059 — A propflowai push refreshes Smith's runtime clone: one webhook, a second repo key, a path filter

The problem, measured

Smith reads propflowai's contract files at runtime from a clone called propflowai-smith. config.propflowai_root() resolves in four steps — SMITH_PROPFLOWAI_ROOT (explicit override), then SMITH_HOME.parent.parent (the pre-split in-tree layout), then $PROPFLOW_ROOT/propflowai-smith (the runtime clone, "the checkout the Construct trusts"), then PROPFLOWAI_DIR (a teammate's primary checkout); candidates 2-4 qualify only if they actually hold config/automations.toml, and none qualifying raises rather than defaulting silently. So on the mini the clone is what Smith reads unless an in-tree copy outranks it — stated in full because an implementer reading "prefers the clone" would miss that candidate 2 can win. And ~/.claude/scripts/smith-sync-from-main.sh:55-56 sets PF_REPO=$HOME/code/PropFlow/propflowai-smith with:

CONFIG_FILES=(config/automations.toml config/channels.toml
              scripts/print-sensor-registry.ts
              src/lib/platform/observability/sensor-registry.ts)

The only automatic caller of that script is the push-deploy in the review webhook, and it is keyed on ONE repo (~/.claude/services/review-webhook/listener.js):

:1485  const SMITH_DEPLOY_REPO = process.env.SMITH_DEPLOY_REPO || 'PropFlow-Technologies/agent-smith'
:1486  const SMITH_DEPLOY_REF  = process.env.SMITH_DEPLOY_REF  || 'refs/heads/main'
:1556  if (repo !== SMITH_DEPLOY_REPO || ref !== SMITH_DEPLOY_REF) { … }

A push to propflowai fires nothing. Both constants are env-overridable, so today's keying is a default rather than a hardcode.

⚠️ This is not a race, it is the default ordering. The ordinary deploy order merges agent-smith first and the propflowai manifest PR second, because the code usually has to land before the manifest describes it. So the sync runs before the manifest lands, and the clone holds the OLD manifest until the next agent-smith push — which may be days away and will be about something unrelated.

Why it matters beyond a stale file: the drift guard that compares Smith's daemon roster against the manifest reads that clone, so through the whole window it compares against the old manifest and either misses a real mismatch or reports a false one. Nothing automatic catches that: the guard skips in CI for want of a propflowai checkout, it is not in ci/cross-repo-tests.txt, and the local pacer refuses it as unchanged. Two independent gaps compose — nothing refreshes the input, and nothing runs the check.

The decision

One webhook, a second repo key, a path filter. The existing push-deploy in the review webhook gains a second repo+ref it will accept, and for that second repo it refreshes the clone only when the push touches a file in the synced set. The agent-smith trigger and the deploy's existing batch-guard behaviour are untouched.

Specifically:

  1. Extend the keying, do not add a mechanism. No second poller, no new service, no cron. ADR-0002 already fixes the shape of coupling between fleet parts, and a poller for a signal that is already arriving as a webhook is a second copy of a thing that works (Constitution §1). The precedent for extending this listener's event handling is ADR-0039.
  2. The second repo's trigger is path-filtered; the first repo's is not. An agent-smith push deploys code and must always deploy. A propflowai push is relevant only when it changes a file Smith reads, and propflowai pushes far more often than Smith does — an unfiltered second key would run the sync on every unrelated propflowai merge.
  3. The filter is derived from the synced set, not restated. CONFIG_FILES in smith-sync-from-main.sh is the existing source of truth for what the clone carries. A second hand-written copy in the listener is drift with a start date (Constitution §1), and the failure mode is silent: a file added to CONFIG_FILES later would sync on an agent-smith push and never on a propflowai one. ⚠️ HOW it derives is left to the implementer, but the failure mode is not. The listener is JavaScript and the source of truth is a bash array, so the options are: parse the script, have the script print the list behind a flag (--print-config-files), or move the list to a shared data file both read. Each has a different failure mode, and the dangerous one is shared: a derivation that quietly returns an EMPTY or unparseable set means no propflowai push ever matches — which is exactly today's defect wearing a new mechanism, and invisible for the same reason. So the pin has a second condition: red on an empty or unparseable derived set, not only on config/automations.toml falling out of it.
  4. State which way a truncated payload fails, and it should fail OPEN. A GitHub push payload carries added/modified/removed per commit, and that detail is truncated on large pushes and absent in some shapes. A filter that reads those lists and finds nothing relevant cannot tell "no relevant path" from "the list was cut", and failing closed there is silent in the same way as the empty-set case. Refreshing is cheap and idempotent, so an indeterminate path list refreshes.
  5. Additive only (Constitution §6): the existing trigger keeps working unchanged, and if the second key is wrong the only loss is the refresh that does not happen today either.

⚠️ A PREREQUISITE THAT IS NOT OPTIONAL, AND IT IS GERA'S

propflowai's webhook to the listener does not deliver push today. Measured 2026-10-15, both repos' hooks for the listener's own endpoint https://gh-webhook.propflowai.co/gh:

propflowai   hook 625249155   push=false
  events: check_suite, deployment_status, issue_comment, pull_request,
          pull_request_review, pull_request_review_comment
agent-smith  hook 653865817   push=true
  events: issue_comment, pull_request, pull_request_review,
          pull_request_review_comment, push

(Each repo also has a webhook-forwarder.github.com hook that does carry push; that is a different destination and does not reach this listener.)

So without push added to propflowai's hook 625249155, every line of this decision is dead on arrival — the listener would be correct and would never be called. ADR-0039 hit the same wall and had to add deployment_status to that repo for the same reason. It is a repo setting, so it is Gera's, and it belongs with the boundary below rather than in the implementing PR. This was raised by the review bot on the PR that landed this ADR, not by the author — the ADR had specified a mechanism whose delivery precondition was absent.

It ships its pin, and the pin is about the trigger's reach

A test that goes RED if the trigger's repo/paths set stops covering config/automations.toml. This is the condition, not a suggestion: a behaviour change here with no pin is how a silent trigger gets dropped in one line later, and the failure is invisible — nothing refreshes, nothing errors, and the drift guard keeps comparing against a stale file exactly as it does today.

⚠️ The pin must fail when the behaviour is removed, not when a string moves. On the dock that produced this ADR, five guards were found in one day that passed on every real case and could fail only on impossible ones — four of them had positive controls and all four passed. A test asserting that a filename appears in the listener's source is a text match wearing a behaviour test's clothes. Mutate the artifact: remove the second repo key, assert red; remove config/automations.toml from the covered set, assert red.

The boundary — what this ADR does NOT authorise

It does not authorise editing or restarting the running listener. The shape is decided; the deploy is a live-service change, and this fleet's standing rule holds infra blast radius — triggers, crons, alarms — for Gera, who owns that service. So the implementing work is: a reviewed change plus its pin, landed as a PR, and the restart named as his step, with the risk stated in a sentence when it is handed over. SYSTEMS.md's review-webhook entry documents the edit procedure (.bak file, node --check, kickstart) and that procedure is part of the hand-over, not a licence to run it unasked.

If that reading is wrong — if Gera would rather the implementing session also deploy it — he says so on block b92097120, which is still open and carries this ruling.

Consequences

What was rejected