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 of tasks/WP##.md frontmatter 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 raw grep of the journal. This does not break polling (status.events.jsonl stays 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

  1. The RFC under-counts what already ships. E1 (charter context --json) is already implemented; E4's proof primitive 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.
  2. 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.jsonl surfaces 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.
  3. One non-negotiable governance line: proofs/exports are observations, never authority. Lane/lifecycle authority is status.events.jsonl reduced by the status FSM, and the approval gate in status/wp_state.py (_check_reviewer_approval) requires structured reviewer evidence carried on the TransitionContext. Proof events are fully decoupled from that gate today (status/ imports proof/ zero times; emit_proof_event has 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 proof event_journal must stay a distinct store from status.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 (the DoctrineService / PackContext / activation-engine stack per ADR 2026-07-15-1) — not the mission read-model. Routing E1 through MissionRegistry would 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 MissionResolver port (src/mission_runtime/mission_resolver_port.pyresolve(handle)/all_missions()), so neither re-walks kitty-specs; but that port is identity-only, so the WP/lane read-model itself is still MissionRegistry (#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-deriving write_scope.

E1 — charter context --action <a> --jsonALREADY SHIPS; formalize the contract

  • Current state: implemented today in src/specify_cli/cli/commands/charter/context.py (--action and --json), backed by charter.context.build_charter_context_json; action vocabulary in src/doctrine/missions/action_index.py (now itself a projection of step.yaml step-authority per the S-B mission — provenance should key off the step/DRG grain, not the flat action index). Emits action-scoped directives, all_directives, tactics, styleguides, toolguides, governance_references, project_charter, and an org_charter block 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 nested org_charter.schema_version at doctrine/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 making config.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> --jsonADOPT (highest-leverage new work)

  • Current state: no lanes CLI 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 into lanes.json, and the collapse math lives only in lanes/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.json reads go through one loader, and there are no bypass sites (the surface already satisfies DIR-044 044-canonical-sources-and-unification). The value is a net-new external-contract projection: it prevents future re-derivation by outside-in tools that cannot import lane_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_scope already 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 subscribeDECLINE the daemon; use #645's planned async transport instead

  • Current state: no streaming/subscribe primitive today. spec_kitty_events is a pure schema/reducer library (no I/O); the dashboard server is request/response only (no websocket/SSE); status/store.py is a batch JSONL reader with no tail/follow.
  • Architecture + duplication lenses: a bespoke subscribe daemon (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.jsonl is 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 its MissionRegistry as the single change-feed subscribe point that makes that step cheap — but that transport is planned, not yet landed (no FastAPI/SSE surface exists in dashboard/ on main today; 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 stdlib server.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 subscribe as 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 on MissionRegistry (#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 proof concept is already a first-class primitivesrc/specify_cli/proof/events.py defines a strict (extra="forbid") schema, PROOF_SCHEMA_VERSION = "1.0.0", 7 event types (ProofItemRecorded, ReviewProofRecorded, TestEvidenceCaptured, BenchmarkEvidenceAttached, SecurityScanCompleted, PullRequestLineageRecorded, HumanApprovalRecorded), ProofActor/ProofSubject (whose actor_type Literal already includes "service"), idempotency keys, bounded envelopes — with an emitter method sync/emitter.py::emit_proof_event into the event_journal (a store separate from status.events.jsonl). Maturity caveat: the primitive is modeled but not yet wiredemit_proof_event has no production call site today, so proof events are a designed-but-dormant stream, not an actively-populated store. Durability caveat: the event_journal is 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 whose coalesce_key is None.
  • Duplication lens (corrected seam): a governed external proof ingress maps to the proof-event path (emit_proof_eventevent_journal, mission/WP-scoped, aggregate inferred by proof/events.py::infer_proof_aggregate) — not to profile-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 bare proof emit is 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.py done-evidence, issue-matrix evidence_ref, retrospective evidence_refs, acceptance-matrix evidence — but they are distinct-by-design across different tiers/lifecycles/authority semantics and should not be collapsed. Only status/emit.py done-evidence and the issue-matrix evidence_ref feed 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 unlock approved/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 stamped actor_type: "service" with a distinct source, never wired as admissible reviewer evidence; (2) reject HumanApprovalRecorded / 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 unique coalesce_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 emit and 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 registerDECLINE as framed (commands-only, via the existing registrar)

  • Current state: a hook-registration seam already exists — HookRegistrar Protocol + ClaudeCodeHookRegistrar.register/unregister own idempotent mutation of .claude/settings.json (src/specify_cli/session_presence/hooks/), invoked internally at init/upgrade to register spec-kitty's own session-start/session-stop commands. spec-kitty is the hook callee, not an instruction injector.
  • Architecture + doctrine lenses: a hooks register that 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 through charter context). An external, per-harness instruction channel is a split-brain doctrine source that no charter context resolution 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 via pack_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.
# 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.