dogwatch

Documentation

What dogwatch is, how to read what it publishes, and where it can fail. Written for someone who has never seen this project before.

What this is, and what it is not

dogwatch is an operated instance, not a product you install. It is one program watching six specific surfaces I run — five showcase sites and the portfolio hub — and publishing what it finds. There is nothing here to sign up for and nothing to configure for your own domain. Nothing is published to npm, and forking this repo to point it at your own surfaces is unsupported: the code is public (MIT) so you can read exactly how it decided what to publish, not so you can run your own copy of it.

It does not watch anyone else's software, does not generate content, and makes no uptime claim. One request, one runner, one region, once a night — a quiet night publishes that it was quiet, with the checks it ran to reach that conclusion.

How to read a run record, field by field

Every published run is one JSON file at runs/<YYYY>/<YYYY-MM-DD>-<runId>.json, canonical (sorted keys, 2-space indent, a trailing newline), validated against the schema published at schemas/run-record.v1.json. These are the fields, grouped by what they're for:

Identity and provenance

runId
a UUIDv7, sortable by time, unique to this run
kind
"scheduled" | "manual" | "gap" — how this run was triggered, never fudged
commit
the exact git commit the runner had checked out
targetsHash
sha256 of targets.json at run time — a config change is visible in the diff
trigger
the workflow name, the Actions run URL, and who or what fired it

Timing

scheduledFor
when the schedule expected this run, if it was a scheduled one
startedAt / endedAt
ISO-8601 timestamps bounding every check this run made

Checks, findings, and metrics

checks[]
every probe made: the request, the evidence captured, the verdict, and a curl line to reproduce it yourself
findings[]
confirmed problems, each with ≥1 source (URL, method, status, retrieval time, and a path into this record's own evidence)
absenceOfEvidence
on a quiet run (and every run), the honest count of what came back clean and what didn't run at all, with reasons
metrics[]
numbers recorded and rendered, never judged — a byte count moving is not an event

Actions, gates, and refusals

actions[]
what dogwatch proposed doing about a finding, and its status through the gate
gates[]
every gate opened this run: its key, status, open/expiry times, and how it was decided
refusals[]
everything dogwatch declined to do, and the exact reason code

Cost

cost
integer micro-USD, its certainty, and a breakdown that must sum to the total
llm
call count, model, token counts — calls:0 on a quiet night, always
degraded[]
any component that fell back to a safer, cheaper mode this run, and why

Integrity

audit
the sluice event log backing every action and gate — namespace, store, sequence range, and whether it verified
chain
this record's own hash, and the previous run's, so a retro-edit to any past record breaks every later one visibly
amendments[]
post-publication facts (a gate decided hours later) — appended, never rewriting what was already published

Open a real run and follow along →

The gate flow, and the three decision channels

A confirmed finding proposes an action — currently, opening an issue. Opening the gate starts a 48-hour timer and notifies two places: always, an issue in dogwatch's own repo (no token needed to read it); optionally, a webhook carrying a single-use tokenized link.

A human decides through exactly one of three channels, every one of them recorded as such:

(a) web
POST /api/gate/decide with the single-use HMAC token from the notification link. Rate-limited at the edge and by an app-level daily counter.
(b) mobile / CI
A workflow_dispatch on resume.yml, authenticated by GitHub repo permissions — no token, no Vercel, works from the GitHub mobile app.
(c) CLI
dogwatch gate decide run locally — break-glass, and recorded as exactly that.

Approval executes the action exactly once, through the same idempotent effect runner every probe uses. Rejection refuses it. So does silence: 48 hours with no decision times out, and the timeout's own default is reject — fail closed. No auto-approve exists anywhere in this product.

Flow diagram of the dogwatch gate: propose, gate opened, decide through one of three channels, execute exactly once. The 48-hour timeout path into REFUSED is drawn in amber, the only edge that fires without a human.

The honesty rubric, in prose

dogwatch verify runs against every committed record on every push. Each rule below has one job and one exact error code — a violation that only warns still fails the build.

  • R1Prevents a run publishing with no checks at all, or a check stuck in a non-terminal state forever.E_NO_CHECKS / E_CHECK_NONTERMINAL
  • R2Prevents a finding pointing at a check that isn't itself verdicted a finding.E_ORPHAN_FINDING
  • R3Prevents a check verdicted "finding" existing with no real finding object behind it.E_UNREPORTED_CHECK
  • R4Prevents a finding existing with no real source: an absolute https URL, a retrieval timestamp inside the run window, and an evidence path that resolves inside the same record.E_UNSOURCED_FINDING
  • R5Prevents a quiet run skipping the honest "checked and found nothing" statement, or that statement's numbers not matching the actual pass count.E_NO_ABSENCE_SECTION
  • R6Prevents a skipped or errored check being silently dropped instead of named with a machine-readable reason.E_SILENT_SKIP
  • R7Prevents an action being recorded as taken with no real, matching audit event behind it.E_ACTION_UNBACKED
  • R8Prevents a gate being recorded as resolved with no real, matching audit event behind it.E_GATE_UNBACKED
  • R9Prevents a cost figure that doesn't sum to its own breakdown, or LLM usage claimed with no provider-reported tokens behind it.E_COST_UNBACKED
  • R10Prevents an advisory note being published with no model call behind it, or citing a finding id or URL outside this record's own evidence.E_ADVISORY_UNGROUNDED
  • R11Prevents a broken or unverified audit hash chain, or a gap between runs going unexplained.E_CHAIN_BROKEN
  • R12Prevents a published record being retroactively edited without it showing — the tamper-evidence check.E_RECORD_TAMPERED
  • R13Prevents a model or a human hand-writing a finding's text — every statement must be re-derived byte-for-byte from stored evidence by the rule that owns it.E_MANUFACTURED_FINDING
  • R14Prevents a metric (recorded, never judged) being dressed up as a finding by carrying a severity.E_METRIC_AS_FINDING
  • R15Prevents a secret-shaped string, or a header outside the published allowlist, reaching a published record.E_SECRET_LEAK

The anti-manufacture rule (R13) and the advisory model, explained further →

Cost accounting, and why micro-dollars

Every published cost.microUsd is an integer, computed from provider-reported token usage multiplied by a pricing manifest committed at pricing.<date>.json — never a hardcoded constant. It is rendered at four decimal places of a dollar, not two: rounding a $0.0055 run to "$0.01" overstates it by two times, and this project's whole argument is that a number should mean exactly what it says.

A quiet night makes no model call at all and costs exactly $0.0000, published as llm: { calls: 0, reason: "no_findings" }. On an eventful night, one advisory model call reads only structured evidence already in the record — never a page body — and is capped at 2 calls per run and a daily ceiling of 20 calls, checked before every call. A trip, an API error, or a schema-invalid response degrades to the deterministic summary standing alone, published as degraded: [{component: "llm", reason: ...}], never a silent failure.

Failure modes

The ugly paths this product has an explicit, published contract for — not a hope that they don't happen.

Neon suspended or over quota
The run degrades to an in-memory store: probes and findings stay valid (they're pure functions of evidence), the record publishes audit.store:"memory" and chain.anchored:false, and no gate can open — every proposed action is refused with reasonCode:"store_unavailable". Fails closed, never fails silent.
The runner is killed mid-run
Nothing is published for that night. The next run emits a kind:"gap" record citing the failed Actions run before its own — a missing night is a published artifact, not silence that looks like health.
Duplicate or overlapping runs
A concurrency group prevents overlapping watch runs. Two run records are legal (they're different runs); duplicate effects are not — intent-derived idempotency keys make a second attempt at the same effect a no-op.
The same finding recurs for 40 nights
One issue, ever. The effect key is the finding's fingerprint with a 90-day retention; nights 2 through 40 publish a refusal with reasonCode:"duplicate_suppressed" instead of filing a second issue.
The GitHub API fails mid issue-create
A retryable failure is retried and published. An indeterminate outcome is never retried and is published verbatim ("we do not know whether the issue was created") — the next run reconciles by searching for a hidden marker and publishes the resolution.
A gate is approved and rejected in a race
First writer wins; the second decision returns the already-recorded outcome. Both attempts appear in the audit trail.
An approval token is stolen or replayed
Single-use, burned by the deciding update, 48-hour expiry, timing-safe comparison. A replay fails with E_BAD_TOKEN. The token itself never appears in any published artifact.
A gate is never decided
48 hours ⇒ timed out ⇒ reject. Fail closed. A published refusal, not an auto-approve — no auto-approve exists anywhere in this product.
Vercel is down
Every page is static and keeps rendering from the last deploy; only /api/gate/decide is gone, so decisions route through workflow_dispatch or the CLI instead. The record JSON committed to git is the canonical source, not the site.
A GitHub or npm API call is rate-limited
The check's verdict is error with the real code, listed in notChecked. Never counted as a pass.
A sibling site isn't deployed yet
skipped with reasonCode:"not_published" and the reason printed — an unbuilt sibling is not a finding.
A finding flaps between runs
A medium finding needs two consecutive runs before any action is proposed; high acts on first sight. Night one publishes status:"unconfirmed".
A record would exceed 512 KB
Evidence bodies are truncated with truncated:true. A truncated check may not produce a high-severity finding.
dogwatch finds a fault in dogwatch itself
Filed in its own repo, ungated (L2) — documented behaviour, not an exception to the rule.

Limitations

  • This is an operated instance, not a product you install. Nothing here is published to npm; forking and pointing it at your own surfaces is unsupported.
  • dogwatch watches six surfaces I operate and makes no claim about anyone else's software.
  • One request, one runner, one region, once a night — this is not an uptime claim. There is no SLA, no paging, and no synthetic performance score.
  • Timings and download counts are metrics: recorded and rendered, never judged, never a finding.
  • artifact, repo, and pkg are registered check families that aren't implemented yet — see the check catalog for the exact reason each is missing.