RFC #2497 — External Observability Endpoints: Squad Assessment
Assessment synthesis, 2026-07-09 (design-alignment corrections applied 2026-07-18). Three profile-loaded lenses — architecture (architect-alphonso), duplication/consolidation (paula-patterns), doctrine/governance (doctrine-daphne) — evaluated the five endpoints proposed in RFC #2497 (author: @OriPekelman) against the current codebase; a second scrutiny pass re-verified every claim against main and the #645 Stable Application API Surface epic's scope; and a second-opinion pass re-checked the recommendations against the architectural direction refined since 2026-07-09 — the formalized doctrine→charter→runtime authority layering (ADR 2026-07-15-1), the WP-runtime-state / event-log-eviction model (ADR 2026-07-16-1), and the landed MissionResolver port (#2173). No endpoint verdict changed across the passes; the refinements below are precision, routing, and new governance guards.
Status: decision-support only. This document captures the ideas and the squad's evidence so a retain/create/decline decision can be made deliberately. RFC #2497 stays OPEN — it should be closed only after the operator decides which endpoints to retain and which issues to create.
Context
@OriPekelman is building an external observability + attestation side-car that wraps the coding agents Spec Kitty orchestrates and produces signed, verifiable records of each run. Its self-imposed constraint: work entirely outside-in — shell out to spec-kitty, read documented on-disk artifacts (status.events.jsonl, lanes.json, meta.json), import nothing from specify_cli, change nothing in core. The RFC surfaces five places where a small additive endpoint would make outside-in integration cleaner for any third-party tool. None is blocking — each has a working fallback today.
Direction note (ADR 2026-07-16-1). The authoritative WP runtime state (claim liveness, subtask progress, activity log, review fields,
agent/assignee) is being evicted out oftasks/WP##.mdfrontmatter into the append-only event log, and the frontmatter fallbacks removed. An outside-in reader must therefore treat the reduced snapshot (the reducer's deterministic fold of the event log) as the authoritative runtime surface — not WP-file frontmatter, and not a rawgrepof the journal. This does not break polling (status.events.jsonlstays append-only — it only gains events, including a new non-transition annotation event class), but it does move where the truth lives.
The RFC's three principles (generally useful, additive/optional, no private surface) are sound and align with the #645 Stable Application API Surface epic. The squad's job was to check each endpoint against reality: what already exists, what consolidates duplication, and what governance guards are non-negotiable.
Headline findings
- The RFC under-counts what already ships. E1 (
charter context --json) is already implemented; E4'sproofprimitive already exists as a first-class, fully-modeled event schema (though presently dormant — see below). So the real question for those two is contract stabilization and trust boundary, not build a feature. - Two distinct keystones are missing — keep them separate. The side-car's premise is signed, verifiable records, but no keyed cryptographic signature/seal exists anywhere on the proof, event-journal, or
status.events.jsonlsurfaces today (only unkeyed content hashes and an idempotency digest, which give no forgery resistance). That gap bounds the authenticity value of E3/E4. It is not, however, what protects governance authority: the authority boundary is enforced architecturally, not cryptographically — the transition gate does not read proof events at all (see finding 3). Signing makes external records verifiable to journal consumers; it does not make an external assertion admissible to any gate. Sequence the signing keystone first for attestation value, but do not conflate it with the authority guard. - One non-negotiable governance line: proofs/exports are observations, never authority. Lane/lifecycle authority is
status.events.jsonlreduced by the status FSM, and the approval gate instatus/wp_state.py(_check_reviewer_approval) requires structured reviewer evidence carried on theTransitionContext. Proof events are fully decoupled from that gate today (status/importsproof/zero times;emit_proof_eventhas no production caller yet). In the vocabulary the layering ADR (2026-07-15-1) formalizes — doctrine offers → charter activates → runtime consumes only activated — an external side-car is none of the three roles: it is a downstream observer that may read the runtime-consumes projection and may write into no authority role. Concretely this is a store-separation invariant: the proofevent_journalmust stay a distinct store fromstatus.events.jsonl, and the status reducer must never fold proof events (status/→proof/imports must stay at zero). ADR 2026-07-16-1 reinforces this — the event log now absorbs more authoritative state (annotation-folded liveness/done inference), so the load-bearing guard — reject external approvals at ingress, keep the stores separate — is doubly load-bearing, and holds independent of signing.
Per-endpoint assessment
Two rankings — separate near-term leverage-to-cost from ultimate value, because E4's value is gated on a whole signing subsystem:
- Near-term leverage-to-cost: E1 > E2 > E3 (cursor) > E4 > E5.
- Ultimate value (once the signing keystone exists): E1 > E2 > E4 > E3 > E5.
Cross-cutting adopt-constraint — project the canonical seam, split by data-domain. Each new read endpoint MUST be a projection of a canonical seam, never a new standalone filesystem reader — #645 forbids per-request filesystem walks from transport/CLI-side modules via an architectural test, and a reader that re-walks disk reintroduces exactly the drift #645 exists to kill. But the seam differs by data domain, and naming one lumped "service layer" is imprecise:
- E1 (
charter context) projects the doctrine/charter activation seam (theDoctrineService/PackContext/ activation-engine stack per ADR 2026-07-15-1) — not the mission read-model. Routing E1 throughMissionRegistrywould be a category mismatch (charter-activation ≠ mission/WP data). - E2/E3 (lane/status read-models) project the mission surface: mission identity resolves through the landed
MissionResolverport (src/mission_runtime/mission_resolver_port.py—resolve(handle)/all_missions()), so neither re-walkskitty-specs; but that port is identity-only, so the WP/lane read-model itself is stillMissionRegistry(#956, OPEN / dashboard-scoped / not yet landed). E2 is therefore gated on #956 for a clean projection, or must be built to swap onto it rather than re-derivingwrite_scope.
E1 — charter context --action <a> --json → ALREADY SHIPS; formalize the contract
- Current state: implemented today in
src/specify_cli/cli/commands/charter/context.py(--actionand--json), backed bycharter.context.build_charter_context_json; action vocabulary insrc/doctrine/missions/action_index.py(now itself a projection ofstep.yamlstep-authority per the S-B mission — provenance should key off the step/DRG grain, not the flat action index). Emits action-scopeddirectives,all_directives,tactics,styleguides,toolguides,governance_references,project_charter, and anorg_charterblock with per-artifact provenance. The RFC's "parse the text output" fallback is stale — the JSON path is canonical. - All three lenses agree: nothing to build; this is a discoverability + contract-stability gap.
- The real gap (doctrine lens): the top-level payload carries no
schema_version. An external attestor pinning to this shape breaks silently on any doctrine-layer reshape. - Recommendation: small hardening issue — stamp the payload with a top-level
context_schema_version(named distinctly from the existing nestedorg_charter.schema_versionatdoctrine/org_charter.py), document it as a stable external contract with a deprecation policy, and emit resolved artifact ids + provenance (never raw pack internals). Route the reader through the doctrine/charter activation seam, not the mission read-model. Two caveats the refined layering adds (ADR 2026-07-15-1 + #2526 makingconfig.activated_*the single activation authority): (a) the payload is activation-scoped — a restrictive org/project charter legitimately shrinks the active set, so an external attestor pins to an activation contract, not a static doctrine dump; (b) that activation surface is itself still stabilizing (the S0–S4 DRG-node reshape and the #2519 freshness work are in-flight), so version the contract and mark it as tracking that stabilization rather than freezing today's shape. Not a new endpoint.
E2 — lanes write-scope --wp <id> --json → ADOPT (highest-leverage new work)
- Current state: no
lanesCLI group exists, but the state is fully computed and persisted:LanesManifest.lane_for_wp(wp_id)is the canonical WP→lane lookup,ExecutionLane.write_scope/to_dict()are serialized intolanes.json, and the collapse math lives only inlanes/compute.py(src/specify_cli/lanes/models.py,lanes/persistence.py,lanes/compute.py). - Duplication lens (corrected): this is not a present-duplication cleanup — the internal tree is already consolidated: all internal WP→lane callers funnel through
lane_for_wp(),lanes.jsonreads go through one loader, and there are no bypass sites (the surface already satisfies DIR-044044-canonical-sources-and-unification). The value is a net-new external-contract projection: it prevents future re-derivation by outside-in tools that cannot importlane_for_wp, giving them one stable read model instead of each re-implementing the collapse semantics as core evolves. - Guards (architecture + doctrine): read-only projection of the #645 service layer; it must reflect, never redefine, allocator ownership. Expose the resolved globs (
write_scopealready is the resolved glob union), not the internal collapse-rule vocabulary (an implementation detail, not a contract). - Recommendation: create an issue under #645. Smallest, safest, highest-leverage of the five.
E3 — event_journal subscribe → DECLINE the daemon; use #645's planned async transport instead
- Current state: no streaming/subscribe primitive today.
spec_kitty_eventsis a pure schema/reducer library (no I/O); the dashboard server is request/response only (no websocket/SSE);status/store.pyis a batch JSONL reader with no tail/follow. - Architecture + duplication lenses: a bespoke
subscribedaemon (its own lifecycle, connection state, backpressure, resume cursors) is the heaviest boundary edge in the set and consolidates nothing (one legit reader today).status.events.jsonlis an append-only, documented log — and it stays append-only under ADR 2026-07-16-1 (eviction moves state into the log; there is no compaction/TTL), so polling it remains a correct decoupled contract. An async push transport is scoped as a #645 deliverable (Sequencing step 6, "async update transport (WebSocket / SSE)"), and #956 frames itsMissionRegistryas the single change-feed subscribe point that makes that step cheap — but that transport is planned, not yet landed (no FastAPI/SSE surface exists indashboard/onmaintoday; the dashboard server is still stdlib request/response). E3 should ride that future change-feed, not a second long-running process and not the legacy stdlibserver.py. - Doctrine lens: any export must be strictly one-way read-only (no ack/cursor-mutation that could rewrite producer state), and its attestation value is bounded by the missing signing seal.
- Recommendation: decline
subscribeas a standalone push daemon. Near-term, publish a documented cursor read-contract (--since <event_id>) on the existing reader — schema-versioned to tolerate new event kinds, because ADR 2026-07-16-1 is actively adding a non-transition annotation event class to the very journal E3 tails. Longer-term, fold the streaming need into #645's planned SSE change-feed onMissionRegistry(#956) once landed, rather than tracking it as a novel endpoint. Gate any attestation framing on the signing keystone.
E4 — proof emit --from-file <f> → DO NOT add a new verb; fence an external-evidence ingress (with signing) — under a dedicated attestation epic, not #645
- Current state: the
proofconcept is already a first-class primitive —src/specify_cli/proof/events.pydefines a strict (extra="forbid") schema,PROOF_SCHEMA_VERSION = "1.0.0", 7 event types (ProofItemRecorded,ReviewProofRecorded,TestEvidenceCaptured,BenchmarkEvidenceAttached,SecurityScanCompleted,PullRequestLineageRecorded,HumanApprovalRecorded),ProofActor/ProofSubject(whoseactor_typeLiteral already includes"service"), idempotency keys, bounded envelopes — with an emitter methodsync/emitter.py::emit_proof_eventinto theevent_journal(a store separate fromstatus.events.jsonl). Maturity caveat: the primitive is modeled but not yet wired —emit_proof_eventhas no production call site today, so proof events are a designed-but-dormant stream, not an actively-populated store. Durability caveat: theevent_journalis append-only by design but not immutable-by-row — a WP08 coalescing/supersession layer (event_journal/coalesce.py) collapses keyed undelivered events latest-wins in place, supersedes delivered events via a sidecar (payload bytes preserved), and never coalesces events whosecoalesce_key is None. - Duplication lens (corrected seam): a governed external proof ingress maps to the proof-event path (
emit_proof_event→event_journal, mission/WP-scoped, aggregate inferred byproof/events.py::infer_proof_aggregate) — not toprofile-invocation complete --evidence, which is an Op-scoped file promotion (invocation/record.py::promote_to_evidence, requires an active invocation). Those are two different seams; the RFC's own "keep it in your own store" fallback is a step backward from the richer canonical primitive. Because proof events are themselves already a store, a bareproof emitis best understood as an unauthenticated external writer into the existing proof store, not a "4th parallel evidence store." (For completeness, the evidence surfaces in the codebase are more than three — invocation Tier-2 evidence,status/emit.pydone-evidence, issue-matrixevidence_ref, retrospectiveevidence_refs, acceptance-matrixevidence— but they are distinct-by-design across different tiers/lifecycles/authority semantics and should not be collapsed. Onlystatus/emit.pydone-evidence and the issue-matrixevidence_reffeed the approval gate.) - Doctrine lens (the sharp risk): today the actor is self-declared and unauthenticated, and there is no keyed signature. An external emit is an unauthenticated write of a self-attesting record. Note the risk is precise: because proof events are decoupled from the transition gate (finding 3), a forged
HumanApprovalRecorded{approved}cannot today unlockapproved/done— the live exposure is to attestation-record consumers (the side-car and journal readers), and to any future wiring that reads proofs into authority. Required guards: (1) external-origin proofs stampedactor_type: "service"with a distinct source, never wired as admissible reviewerevidence; (2) rejectHumanApprovalRecorded/ approving verdicts from the external ingress entirely; (3) add a signature/integrity seal so external emits are verifiable rather than forgeable — which is also exactly the RFC's own value prop; (4) durability — because journal rows can be coalesced/superseded, reduced snapshots re-folded (last-writer-wins), and derived artifacts re-derived by migration, an external attestor must carry its own durable copy of what it signs and emit external proofs with a null or uniquecoalesce_key, never treating a pointer into spec-kitty's journal or reduced snapshot as a permanent immutable anchor. Guard (2) is load-bearing on its own; guard (3) adds authenticity, not authority; guard (4) protects the attestor from Spec Kitty's legitimate internal churn. - Recommendation: the genuinely valuable net-new surface, but not as
proof emitand not under #645 (which is a stable read/query surface — E4 is a write ingress plus a cryptographic attestation subsystem, a distinct security/attestation concern). Create, under a dedicated attestation/security epic (to be filed — no existing epic covers this; #920 is a closed mission-state-migration epic, not a home for it): (a) an attestation-signing keystone issue (prerequisite, unblocks the authenticity value of E3+E4), and (b) a governance-fenced external-evidence ingress design issue on the proof-event path (external-origin guard; observations-not-authority; store-separation preserved).
E5 — session hooks register → DECLINE as framed (commands-only, via the existing registrar)
- Current state: a hook-registration seam already exists —
HookRegistrarProtocol +ClaudeCodeHookRegistrar.register/unregisterown idempotent mutation of.claude/settings.json(src/specify_cli/session_presence/hooks/), invoked internally atinit/upgradeto register spec-kitty's ownsession-start/session-stopcommands. spec-kitty is the hook callee, not an instruction injector. - Architecture + doctrine lenses: a
hooks registerthat injects standing instructions inverts a bounded context (DIR-031) and, worse, bypasses charter authority — standing instructions are doctrine, and the charter is the single canonical authority for them (resolved throughcharter context). An external, per-harness instruction channel is a split-brain doctrine source that nocharter contextresolution knows about and no terminology/doctrine gate audits. - Recommendation: decline the "external tool injects standing instructions" framing — ADR 2026-07-15-1 makes it a clear bypass of all three authority layers (it is neither an offer in a pack, nor a charter activation, nor a consumed activated element). If a side-car needs its shim present at session start, permit registering a command (the side-car's own hook binary) via a public extension of
ClaudeCodeHookRegistrar. Route any instruction content the ADR-sanctioned way: an external tool that wants to contribute standing instructions offers a doctrine pack (a layer overlay viapack_roots) that the charter activates — never a hand-authored file or a runtime hook injection. Optional narrow issue only if the commands-only extension is wanted.
Summary & recommended tracker actions
| # | Endpoint | Verdict | Recommended action |
|---|---|---|---|
| E1 | charter context --json |
Already ships | Small hardening issue: add top-level context_schema_version (distinct from nested org_charter.schema_version); project via the doctrine/charter activation seam (not the mission read-model); mark activation-scoped + version across the in-flight #2519/S0–S4 stabilization |
| E2 | lanes write-scope --json |
Adopt | Create issue — net-new external-contract projection of lane_for_wp()/write_scope; resolve identity via the landed MissionResolver port, project the read-model via MissionRegistry (#956, pending). Highest near-term leverage |
| E3 | event_journal subscribe |
Decline daemon | No push daemon. Near-term: schema-versioned --since <cursor> read-contract (new annotation event class incoming). Longer-term: fold into #645's planned SSE change-feed on MissionRegistry (#956) — planned, not yet landed. Gated on signing for attestation |
| E4 | proof emit |
Adopt-with-guards | Create 2 issues under a dedicated attestation/security epic to be filed (not #645; #920 is unrelated/closed): attestation-signing keystone (prereq) + governance-fenced external-evidence ingress on the proof-event path (emit_proof_event; external-origin guard; null/unique coalesce_key; attestor keeps its own durable copy; store-separation preserved; observations-not-authority) |
| E5 | session hooks register |
Decline as framed | No instruction-injection API. Optional narrow issue: commands-only HookRegistrar public extension |
Cross-cutting: the attestation-signing seal is the true keystone for authenticity — the missing prerequisite that gives E3 and E4 their verifiable-attestation value, and it should be sequenced first. It is separate from the authority guard (observations-≠-authority), which is enforced by the gate not reading external writes and must hold regardless of signing.
Next step (operator): decide which of the above issues to create — E1 under the doctrine/charter activation seam (activation-scoped contract); E2/E3 under #645's mission read-model (MissionResolver port for identity, MissionRegistry/#956 for the read-model); E4's signing keystone + fenced ingress under a dedicated attestation/security epic to be filed — then close RFC #2497 with a summary of what was retained. This assessment does not close it.