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
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/decidewith 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_dispatchonresume.yml, authenticated by GitHub repo permissions — no token, no Vercel, works from the GitHub mobile app. - (c) CLI
dogwatch gate deciderun 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.
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, andpkgare registered check families that aren't implemented yet — see the check catalog for the exact reason each is missing.