Vitals — Capsule Stream Specification
schema: vitals/v0.1 · spec_revision: 0.1-draft.5 · status: DRAFT (not yet frozen)
transport: NDJSON / UTF-8 · media type: application/x-vitals-stream (.ndjson) · reference producer: vitals-maven ≥ 0.1.0
The normative artifact is the JSON Schema at stream.schema.json. When this prose and the schema disagree, the schema wins. RFC 2119 / RFC 8174 keywords (MUST, SHOULD, MAY…) are normative only when capitalized.
1. What Vitals is
Vitals is a tool-neutral format for build lifecycle and diagnostics, streamed as NDJSON so AI agents, CI systems, and humans can consume a build without scraping console text. A stream is a sequence of capsules — one JSON object per line, each an envelope plus a body chosen by its kind. Exactly one capsule, the Pulse, is the small, deduped, authoritative roll-up a consumer reads first; everything else is drill-down.
The data model is a hierarchy — Build ⊃ Unit ⊃ Step — that fits Maven, Gradle, and npm/pnpm workspaces losslessly. It is deliberately narrower than a universal build protocol: arbitrary action DAGs, dependency graphs, remote-execution topology, and watch/daemon sessions are explicit non-goals (one stream = one build invocation).
2. Design principles
These five break every tie:
- Facts, not rendering. The stream records what happened, in time order, losslessly. Token-efficient renderings (tables, summaries) are consumer-built projections, never the system of record.
- Line independence. One capsule per line, with identity, attribution, severity, timestamp, and message all inline — a grepped line is interpretable with zero cross-line lookups. Invocation constants live once in the
StreamHeader. - Spend tokens on semantics, not plumbing. Semantic fields keep descriptive names; per-line repetition of invocation constants is eliminated.
- Open-world consumers, closed-world producers. Consumers MUST ignore what they don’t know; producers MUST NOT redefine what the schema owns. This is what makes additive evolution real (§10).
- Never break the build. A producer failure yields a
Metacapsule and a possibly incomplete stream — never a failed or altered build. Observation is side-effect-free.
3. Stream model (transport, normative)
A stream is a sequence of capsules, one per line, each a complete valid JSON object, UTF-8, LF-terminated. Producers MUST emit LF; consumers MUST accept LF and CRLF.
- Canonical sink. A channel the producer owns exclusively for the stream’s duration (a truncate-on-open file, a pipe, or a dedicated descriptor). Writing to a shared stderr that also carries human log text is non-conformant.
- Atomicity. A single writer serializes complete records; two capsules never interleave within a line. A tailing consumer MUST treat a record as complete only once it is LF-terminated and parses.
- Flush. Producers MUST flush on
*Started,*Finished, error-severity Diagnostics, and terminal capsules. A flush makes bytes visible; it is not an fsync. - Truncation tolerance. The final line of a crashed build may be truncated. Consumers MUST tolerate a single unparsable trailing line and SHOULD count and skip unparsable interior lines.
- Embedded newlines (the #1 corruptor) MUST be carried as escaped
\ninside JSON strings, serialized by a real JSON encoder — never string concatenation. - Line size MUST NOT exceed 64 KB (UTF-8 bytes after escaping). Consumers MAY drop longer lines and SHOULD count them.
- One stream = one producer invocation. Daemon producers rotate the sink (and
build_id) per session.
4. StreamHeader
The mandatory first capsule (stream_seq: 0). A stream without it is non-conformant; consumers that slice streams MUST retain line 1. It states the invocation constants once so no other line repeats them.
| field | req | meaning |
|---|---|---|
event, kind |
✔ | both "StreamHeader" |
stream_seq |
✔ | 0 |
ts |
✔ | RFC 3339 UTC, ms precision, Z |
schema |
✔ | "vitals/v<major>.<minor>" — the compatibility key consumers dispatch on |
spec_revision |
✔ | exact immutable revision, e.g. "0.1-draft.5" — validators pin to this |
schema_uri |
– | stable URI of the schema (a hint; consumers MUST NOT auto-fetch, §9) |
schema_sha256 |
– | SHA-256 of the bundled schema, for trusted binding |
build_id |
✔ | opaque id unique per invocation (UUID recommended) |
producer |
✔ | {tool, tool_version, impl} |
capabilities |
– | what this producer can emit, so consumers tell “unsupported” from “missing” (§11) |
root |
† | absolute path of the top-level build dir; REQUIRED when any relative path appears |
privacy |
– | declared redaction posture {mode, redacted} (§9) |
style |
✔ | "contextual" (default) or "verbose" |
{"event":"StreamHeader","kind":"StreamHeader","stream_seq":0,"schema":"vitals/v0.1",
"spec_revision":"0.1-draft.5","build_id":"0d9e4c2a-7b31-4f8e-9a12-c3d5e6f7a8b9",
"ts":"2026-07-12T08:30:02.621Z",
"producer":{"tool":"maven","tool_version":"3.9.9","impl":"vitals-maven/0.1.0"},
"capabilities":["diagnostics","tests","work-avoidance","portable-paths"],
"root":"/home/u/proj","privacy":{"mode":"portable","redacted":true},"style":"contextual"}
5. Capsule envelope
Fields that may appear on any capsule. Schema-owned names — producers MUST NOT repurpose them.
| field | req | meaning |
|---|---|---|
event |
✔ | producer-native name (open set). When there is no richer native name, equals kind. Consumers MUST NOT branch on it. |
kind |
✔ | tool-neutral classification (closed set) — the consumer’s dispatch key |
stream_seq |
✔ | monotonic record counter, 0 on the header, +1 each record, no gaps. Detects interior loss/reorder; cannot detect a truncated tail. |
ts |
✔ | absolute RFC 3339 UTC instant. Wall clock — never subtract timestamps for durations; use duration_ms, order by stream_seq. |
th |
– | opaque thread/lane id under parallel builds |
unit |
– | full unit coordinate, stream-unique (Maven groupId:artifactId:version; npm name@version) |
step |
– | producer-scoped step id (Maven plugin:goal@executionId) |
eid |
† | execution id — the primary correlation key. Set on *Started, echoed on the matching *Finished; REQUIRED on every non-skipped Unit/Step span and every execution-scoped child (Diagnostic, Output, TestSummary, TestCaseFailed) |
parent_eid |
– | eid of the enclosing span, per the fixed parent-kind matrix (§8) |
retry_of_eid |
– | on a whole-span re-execution: the eid it supersedes (latest in a chain is the final outcome) |
status |
– | on *Finished: success | failure | cancelled | skipped |
work |
– | on success: executed | up-to-date | cache | no-source (an up-to-date test step did not re-run) |
duration_ms |
– | elapsed span time from a monotonic clock; absence = unknown, not instant |
exit_code |
– | raw exit code on external-process StepFinished |
exception |
– | {type, msg, stack?, causes?}; stack is a capped string array |
Extension fields — ext. All non-schema fields live inside a single schema-owned ext object keyed by a reverse-DNS namespace the extender controls, e.g. "ext":{"net.sourceforge.pmd":{"priority":3}}. The top level stays reserved for the schema; cross-tool logic uses only schema-owned fields. Keys under ext beginning vitals. are reserved for future schema use.
Naming (normative). Keys are lowercase snake_case with a closed whitelist of abbreviations (ts, msg, loc, line/col, stack, fp, dir, abs_path, th, eid). kind/event values are PascalCase type names; every other enum value is lowercase-kebab (success, up-to-date).
Numbers/time (normative). Every number MUST be an exact IEEE-754 double (0 ≤ n ≤ 2⁵³−1). loc positions are 1-based positive; an end MUST NOT precede its start. ts has exactly three fractional digits and a literal Z.
6. The kind taxonomy
Closed set in v1 — consumers MUST ignore unknown kinds. Fourteen kinds:
| kind | semantics |
|---|---|
StreamHeader |
stream context (§4) |
BuildConfig |
what the build was asked to do |
BuildStarted / BuildFinished |
outermost span |
UnitStarted / UnitFinished |
a buildable unit (Maven module, Gradle project) |
StepStarted / StepFinished |
a unit of executable work inside a unit |
Diagnostic |
one normalized finding (§6.3) |
TestSummary / TestCaseFailed |
one aggregate per test-step execution / one finally-failed case (§6.5) |
Output |
captured stdout/stderr, attributed and capped |
Pulse |
terminal, deduped outcome roll-up — read first (§6.4) |
Meta |
producer self-diagnostics; never affects the build |
Build status ≠ stream state.
status/outcomedescribe the build. Whether the stream is complete, truncated, or corrupt is a separate, consumer-derived judgment (§8.5) — a dead producer cannot report its own death.
6.1 Span capsules & terminal statuses
Six kinds share the start/end span pattern at three scopes (Build ⊃ Unit ⊃ Step). The end node’s status carries the outcome: success (+ work), failure (+ exception/exit_code), cancelled (+ reason, caused_by), or skipped (a single-node Finished with no Started, no eid). Process death emits no line at all — detected via the unmatched Started (§8). UnitStarted carries dir (unit basedir relative to root), the anchor for every loc.path in the unit.
6.2 BuildConfig
Emitted once before BuildStarted. Closes the “what was asked” gap: targets (required), plus optional cwd, command_display (redacted, display-only), args (machine-usable, redacted per §9), parallelism, offline.
6.3 Diagnostic — the flagship body
One normalized finding, semantic core inline.
| field | req | meaning |
|---|---|---|
tool |
✔ | producing analyzer (javac, checkstyle, pmd, spotbugs, resolver, surefire…) |
rule_id |
✔ | stable {tool}:{rule} id, e.g. checkstyle:UnusedImports |
severity |
✔ | error | warning | info | ignore — the only cross-tool comparable level |
msg |
✔ | short human header (≤ 2,000 chars) |
detail |
– | multi-line elaboration (≤ 8,000 chars) |
loc |
– | optional location; producers MUST NOT fabricate one |
fp |
✔ | producer opaque occurrence/dedup token; normative dedup key is (unit, fp). Stable across rebuilds of the same source, not across edits |
fp_scheme |
– | e.g. vitals/occurrence/v1 |
stable_fp |
– | optional cross-build-stable fingerprint (no line number) for multi-round repair; 64-char SHA-256 hex |
stable_fp_scheme |
– | core value vitals/stable/v1; REQUIRED when stable_fp is present |
Location & path resolution (normative, load-bearing). The repair loop ends with “open the file and edit it,” so a Diagnostic must resolve to a real path. Never assume module dir = module name (a module demo-web-service can live in server/). loc.path is relative with forward slashes; loc.path_base (unit | root) names the anchor; dir on UnitStarted/Pulse.units[] gives the unit’s basedir. Resolution: path_base:"unit" → normalize(checkout_root ⊕ dir(unit) ⊕ loc.path), else against root. Safety: normalize before join, resolve .. lexically, and a consumer MUST verify the resolved path lies inside its workspace before opening it. Resolver diagnostics have no location (never point them at pom.xml).
vitals/stable/v1 fingerprint (normative). A reproducible digest so two producers (and one producer across line drift) agree. Ordered string components: c0=rule_id; c1=version-independent unit identity; c2=located path (path_base + / + normalized path, line/column excluded); c3=normalized msg (NFC, trimmed, whitespace collapsed). SHA-256 of the UTF-8 JSON array [c0,c1,c2,c3], emitted as 64-char lowercase hex. A different recipe MUST take a new scheme name.
6.4 Pulse
Emitted once, immediately before BuildFinished. Small, deduped, authoritative — sourced from the build tool’s own result records, not replayed from the stream. The entry point for every consumer.
{"event":"Pulse","kind":"Pulse","ts":"…","outcome":"failure","duration_ms":52704,
"totals":{"units":4,"succeeded":1,"failed":1,"cancelled":1,"skipped":1,
"diagnostics":{"error":3,"warning":7,"info":0,"ignore":0},
"tests":{"total":342,"passed":338,"failed":2,"skipped":1,"flaky":1}},
"units":[
{"unit":"com.example:api:1.0.0","dir":"api","status":"success","duration_ms":21033},
{"unit":"com.example:core:1.0.0","dir":"server","status":"failure","duration_ms":30122,
"failed_steps":["compiler:compile@default-compile"],
"exception":{"type":"…CompilationFailureException","msg":"Compilation failure"}}]}
outcome ∈ success | failure | cancelled and MUST equal BuildFinished.status. totals.units = succeeded + failed + cancelled + skipped. totals.diagnostics is a dense four-key map, post-dedup by (unit, fp). totals.tests aggregates only the final execution of each test step (latest eid in a retry_of_eid chain), omitted when no test ran. Each units[] carries dir, so pulse + diagnostics alone resolve every path. The pulse does not carry per-step work — the up-to-date-test trap requires consulting StepFinished.
6.5 Output · Meta · Tests
- Output — for opaque child-process steps (npm scripts):
channel,text(ANSI-stripped, ≤ 8,000 chars),seqreassembly key, losslesscontinued/splitvs. honestdropped_*_bytes. In-process producers (Maven) normally omit it. - Meta — producer self-diagnostics:
category,msg,impact ∈ none | diagnostics-incomplete | tests-incomplete | output-dropped | stream-incomplete. Emitted only by the producer; a consumer that detects a problem MUST NOT append to the stream. - Tests — the Diagnostic pattern applied to tests: one
TestCaseFailedper finally-failed case (flaky cases counted, not itemized), oneTestSummaryper test-step execution joined to its cases byeid, withtotals = {total, passed, failed, skipped, flaky}. A case-level retry folds intoattempts/flaky; a whole-step retry gets a neweidand a secondTestSummarylinked byretry_of_eid, soPulsenever double-counts.
7. Producer rules
- Lifecycle (normative cardinality).
StreamHeader(once,stream_seq:0) →BuildConfig→BuildStarted→ zero or more Unit/Step/Diagnostic/Output/Test*/Meta →Pulse→BuildFinished(the last record — the sole completeness marker; nothing follows it). A stream without a validBuildFinishedis incomplete, which is not the same as schema-invalid. - Concurrency. The sink is the single synchronization point: one fully-formed line per
emit(), one writer. Multi-process producers proxy to that writer.stream_seqgives total order; no wall-clock ordering is promised. - Size caps (normative).
msg2,000 ·detail8,000 ·stack20 frames ·Output.text8,000 ·TestCaseFailed.output4,000 · line 64 KB (hard). On truncation, cut at the cap, append…, set the sibling*_truncatedmarker. - Failure isolation. Producer code MUST NOT throw into the build tool. Internal errors become
Meta; if the sink fails, the producer degrades to inert.
8. Consumer rules
- Baseline. Parse line-by-line; tolerate one truncated trailing line; skip-and-count unparsable interior lines. Dispatch on
schema; ignore unknownkind/event/fields. Never assume global ordering — key onunit/step/eid/th, order bystream_seq, never wall-clockts. - Span pairing & the parent_eid matrix (normative). Pair
Started(eid=x)with the first subsequentFinished(eid=x); askippedFinished never pairs; an unmatchedStartedat end-of-stream is the probable crash site. Closed matrix: a Unit’s parent is a Unit or a Step; a Step’s parent is a Unit or a Step; a Build is the root (noeid). - The agent repair loop (recommended). ① read
Pulse— success ⇒ stop; ② dedupe by(unit, fp); ③ layer compile ▸ test ▸ lint (fix compile first — errors cascade); ④ group by file, rank by severity·count; ⑤ edit one file bottom-up → rebuild → new stream. ~1–2K tokens/iteration vs. 12–31K for the raw stream. - Stream state (§8.5, consumer-derived).
COMPLETE(validBuildFinished+ semantic validation passed),INCOMPLETE(header but no valid terminal — outcome unknown),CORRUPT(interior parse/schema/sequence violation),UNKNOWN(too few records). An INCOMPLETE stream is not a failed build.
9. Security & privacy (normative)
A Vitals stream routinely carries sensitive data and its consumers may act on it, so security is normative. Producers MUST strip userinfo and auth params from URLs, mask secret-bearing command-line flags (args_redacted:true), and SHOULD scrub secret patterns from free text. Consumers MUST treat all free-text fields as untrusted (never execute, template, or shell-interpolate them), MUST apply §6.3 path normalization and the workspace-boundary check before opening a resolved path, and MUST NOT auto-fetch schema_uri (SSRF) — resolve spec_revision through a trusted registry and verify schema_sha256. The privacy header (mode: local | portable) documents posture but does not assert the stream is clean.
10. Versioning & evolution
Two identifiers, two jobs. schema (vitals/v<major>.<minor>) is the compatibility key consumers dispatch on. Additive changes — new event names, new optional fields, new kind values — bump the minor (v0.1 → v0.2). Removal, retyping, or semantic change of a schema-owned field is breaking ⇒ bump the major, published as parallel schema files. spec_revision is the exact immutable draft within a schema value that validators pin to. The JSON Schema for a given spec_revision is the tie-breaker over this prose.
11. Conformance
A producer is conformant if its records pass the body schema for its spec_revision, it honors stream cardinality/ordering, keeps eids unique and stream_seq gap-free, obeys its declared capabilities, enforces size/redaction/failure-isolation rules, and passes positive and negative fixtures. A consumer is conformant if it dispatches on the schema major, ignores unknown fields/kinds, never subtracts timestamps for durations, tolerates a truncated tail, distinguishes an incomplete/corrupt stream from a failed build, and resolves paths with the workspace-safety check.
Every producer ships a profile pinning its native event → kind map, unit/step identity, severity mapping (the only cross-tool level — e.g. PMD priority 1–2→error, 3→warning, 4–5→info; SpotBugs 1→error, 2→warning, 3→info), path mapping, ext namespaces, fingerprint algorithm, and known lossiness.
Semantic validation (normative). Per-record JSON Schema cannot express the cross-record contract, so a semantic validator checks the whole stream and classifies failures as CORRUPT, INCOMPLETE, or WARNING. Key rules: header first exactly once at stream_seq:0; stream_seq +1 with no gaps (interior gap = CORRUPT, missing tail = INCOMPLETE); lifecycle cardinality; eid uniqueness and Started/Finished pairing; parent_eid/retry_of_eid integrity; execution-scoped children reference a real eid; BuildFinished.status == Pulse.outcome; Pulse.units[] agree with their UnitFinished; aggregate arithmetic closes; path-safety (an escaping path or path_base:"unit" without a unit is CORRUPT).
Changelog
vitals/v0.1·0.1-draft.5(current — draft, not yet frozen) — the working draft of the format described above. Every record carriesstream_seq(0…N, no gaps);eidon every non-skipped span and on execution-scoped children;path_baseon locations; theextnamespace container; densetotals.diagnostics;failed_stepsas an array.
Freeze of the format is gated on the machine JSON Schema, the semantic validator (§11), and a cross-tool golden corpus once a second producer (Gradle) lands. Until then, spec_revision advances as 0.1-draft.N under a stable vitals/v0.1; additive changes bump the schema minor, breaking changes the major (§10).