Skip to content

OODA: signals the loop can see (PR 2 of 5) - #200

Merged
hhff merged 15 commits into
mainfrom
ooda-signals
Nov 13, 2026
Merged

hhff merged 15 commits into
mainfrom
ooda-signals

Conversation

@hhff

@hhff hhff commented Nov 11, 2026

Copy link
Copy Markdown
Member

PR 2 of 5 for the inspectable OODA loop (spec and plan in #193). Stacked on #193; GitHub retargets this to main when #193 merges.

What changes

  • Stages and provenance, computed at read time. The schema gains x-ooda: each record type and observation kind mapped to its stage (Observe, Orient, Decide, Act, Learn, or measurement/inbox), and the fixed source vocabulary (sensor, metrics, spend, owner, owner-answer, owner-thread, owner-chat, retro, worker, planner, supervisor). stageOf(o) and provenanceOf(o) compute them; nothing in storage changes, so older kits read new lines as before. log --json, dashboard-data and the dashboard's observations carry stage and provenance. observe --source keeps to the vocabulary: the kit's own sources (sensor, metrics, spend) and another writer's are refused.
  • Spikes over the hours a sensor heard. run/sensors/<name>/listened.json marks every hour a probe ran or a stream listened. The median is over those hours only, and a spike needs 24 of them. This is what raised four false spikes on Stacks after its box was down overnight. The detail now names the fingerprint id, with the template inside the untrusted-data frame.
  • One recurring signal per fingerprint. Orient groups a sensor's qualifies and spikes by fingerprint, not by the hour-stamped source_key, so they recur. Any observation a live decision cites counts as explained.
  • Noise rulings: decide --class orient-noise --refs <signal key> <o-…> -- "<why>", a constitutional class. It clears the signal, and an owner's correction brings it back.
  • Set-asides cite what they set aside. ack refs the finding's observations; sensor ignore refs the fingerprint's observations; newest 40 of each.
  • sensor-untriaged: a sensor escalation of severity 3+ older than triage_overdue_hours (2) with no incident, ignore or noise ruling, looking back 7 days, while the supervisor runs.
  • Docs: supervisor.md §3 (readings vs observations, noise rulings, signals by fingerprint) and constitution §2 (orient-noise).

On upgrade

  • Spike warm-up. A sensor with no listened.json raises no spikes until it has heard 24 hours (about a day). Qualifies, downs and incidents are unaffected.
  • New log --json keys. Every object gets stage, and observations get provenance.

For the operator's review (#190 follow-up)

Read provenance.from (in log --json, an operator read) instead of observation.source, with x-ooda.sources as the vocabulary. metrics lists sensor-untriaged. The weekly learning record and orientation debt come in PRs 3–5.

Tests

test/ooda-signals.test.mjs (6 tests): provenance and stage for every writer, observe --source refusals, spikes with a listening gap, one signal per fingerprint and the noise ruling's correction, set-aside refs and the 41 → 40 cut, sensor-untriaged and its hand-off, the kit lesson tag. The chain's spike step seeds listened.json. Full suite on Node 24: 377 passed, 0 failed, 1 skipped. CI covers 18/20/22/24.

One-way doors

None. Storage is untouched apart from observe --source refusing values outside the vocabulary.

🤖 Generated with Claude Code

hhff and others added 13 commits November 11, 2026 12:01
The owner can't tell where an observation came from or where it sits in
the loop. The schema now maps each type and observation kind to its
stage (x-ooda.stages) and names the sources (x-ooda.sources). Both are
computed when a record is read, never stored: source and detector
already mean things older kits read (a sensor's name, repo or prod on a
goal reading, detector sensor on the kit's own lines), so rewriting
them would have an older kit on a mixed fleet misread new lines.

log --json, the dashboard's observations and peek carry stage and an
observation's provenance. observe --source refuses the kit's own
sources and another writer's; older free text still writes as given
and reads as its writer's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Stacks four spikes fired after the box was down 03Z-13Z: the spike
check filled the hours nobody was listening with 0, so the median was 0
and a normal hour passed. Each sensor now records the hours it heard
(listened.json beside counts.json: a check that ran, or events that
arrived), and the median is over those alone, 24 of them at least. The
file is pruned with the counts.

A spike now names the fingerprint's id, frames a log template as
untrusted production data, as log-error does, and counts whole days
(floor), never "0 day(s)".

A sensor just upgraded has no heard hours yet, so no spike fires for
its first 24 heard hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at they set aside

A spike's source_key carries its hour, and Orient grouped on
source_key, so no two spikes ever shared a key and Stacks's spikes
could never reach Orient. Orient now keys a sensor's qualifies and
spikes on the fingerprint's id (signalKeyOf).

"Say so in an observation" silenced nothing. A noise ruling is now a
decision, orient-noise (constitutional: correctable, no rung), that
must cite the observations it explains; Orient treats ids a live
decision cites as explained, so the owner's correction of a ruling
brings the signal back. The Decide feed doesn't call a ruling "not
acted on yet".

ack and sensor ignore now cite the observations they set aside, the
newest 40 to keep the line small, with the finding's key or the
fingerprint in source_ref for the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
incident-unowned needs an incident to exist first, so a sensor's
escalation that nobody folded, ignored or ruled noise was never raised
again: Stacks's spikes sat untouched. sensor-untriaged names each
fingerprint whose escalation of severity 3 or more is older than
sensor_defaults.triage_overdue_hours (default 2) while the supervisor
runs, with no incident holding it, no ignore and no live noise ruling
citing it. Once an incident holds it, incident-unowned takes over. It
looks back 7 days, so an upgrade doesn't raise every escalation the log
ever held, and skips sensors no longer in the settings, as triage does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
supervisor.md's Observe now says a reading is a measurement and never a
signal, and that an observation's provenance and stage are computed
(read provenance.from, not source). The spec's §2 is rewritten for the
read-time design and why: source and detector already mean things
older kits read, so rewriting them would break a mixed fleet. It also
records the listened.json file, the 7-day look-back of
sensor-untriaged, and that fix-regressed and recurred keep their own
recurrence keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-line hint after orient's signals changed the single-repo golden
(G1), which is never re-recorded; supervisor.md step 4 already says how
to explain a signal. stageOf looks a kind up with Object.hasOwn, so an
observation of kind "constructor" reads as Observe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ignal

Review fixes: one definition of what explains an observation (explained()),
shared by Orient and sensor-untriaged. A live orient-noise ruling that names
a signal key covers that signal's later observations, and an open incident
holds its fingerprints. The orient test no longer fails before noon UTC
(its lines span five days). sensor-reconsidered is a Decide-stage set-aside,
and a hand-written goal reading is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes from the upstream loop: an owner-* source on a line the owner
didn't write needs a ref that checks out (an answered question, the owner's
turn or thread), or it reads as its writer's; observe refuses it without
one. thread-resolved reads as the owner's only from the owner. The
supervisor no longer writes source worker or planner. A set-aside's refs are
trimmed to fit beside a long reason. supervisor.md says when to use
--source owner-chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review pushback from the upstream loop: a ruling that names a signal key
covered the fingerprint forever, so a later escalation (same template,
another cause) was never asked to be triaged. It now covers the signal's
observations up to the ruling; one after it gets a fresh look, in Orient
and in sensor-untriaged. A fingerprint that is always noise is for sensor
ignore. Tested both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dashboard-data's observations now carry stage and provenance, and peek
ticket says stage null. Each is a key added beside what was there (the only
removed lines are ones that gained a trailing comma), so the golden is
recorded again rather than the views narrowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hhff
hhff changed the base branch from ooda-loop-inspectable to main November 11, 2026 19:29
@hhff

hhff commented Nov 13, 2026

Copy link
Copy Markdown
Member Author

Fable reviewed it, and its findings are fixed: owner-* provenance needs a verified ref, thread-resolved reads as owner-thread only from the owner, the supervisor can't claim worker or planner sources, and a noise ruling covers only observations before it. Every box instance is now on v0.41.1 or later, so its log lines are tolerated. CI 9/9 at 350237b with main merged in.

@hhff
hhff merged commit f547063 into main Nov 13, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant