Execution Log: Add streaming support from the-loop's service to control plane
Append-only log of progress for the user's visibility. Checked in alongside the spec at
docs/specs/<id>/execution-log.md. The-loop keeps the work item's phase label in the ticketing system in sync with thephasefront-matter above, and self-checks (runs tests at logical checkpoints) recording the outcome here. The log doubles as the resume anchor for context resets (reference/context.md): every reset (clear or compact) is preceded by a checkpoint entry here, and a fresh window re-enters by reading the latest entry's Next: first.
Phase transitions
| Phase | Entered | Reviewed/approved by | Notes |
|---|---|---|---|
| phase-selection | 2026-08-16 | @MadaraUchiha-314 | brainstorming, requirements-approval and critic-review declared skipped; design-critic-review not selected. Outer loop iterates on a pull request. |
| requirements-definition | 2026-08-16 | n/a — requirements-approval skipped | 6 requirements. The transport choice (SSE vs WebSocket) is deferred to design with its constraints fixed here, CORS parity among them. |
| design | 2026-08-16 | @MadaraUchiha-314 (PR #244) | SSE over WebSocket, decided on CORS parity. Two UI prototypes under design/. Risk tier 4 — the CLI config schema changes, so the security review needs a named human sign-off. |
| test-planning | 2026-08-16 | @MadaraUchiha-314 (PR #244) | 13 rows, 10 in scope. R1.6's CORS parity is deliberately not automated — a test client cannot prove it; T12 does it from a browser. |
| tasks-breakdown | 2026-08-16 | n/a — the plan's human read is human-approval | 16 tasks, six of them startable at once: the service chain and the UI chain share no file. |
| implementation | 2026-08-16 | All 16 tasks done. One design deviation (the broker is router-owned, not lifespan-owned) and one replanned test row, both recorded below. | |
| verification | 2026-08-16 | 11 of 15 activities ran and passed; the four that need a browser did not, and are escalated rather than ticked. | |
| needs-review | |||
| complete |
Pull requests
A work item may be delivered by several PRs (a spec PR then an implementation PR, a stacked series, a follow-up after review, or one PR per contributing repository — the multi-repo shape, where the outer loop stays in the repository the ticket was created in and each other repository gets its own PR and inner loop) — list every one of them here, not just the latest. Name the repository in the PR column when it is not this one. Each PR carries the auto-execute label so its activity routes back to this work item's session, and the work item is complete only once all of them are merged or closed (
finish-tasks).
| PR | Scope / tasks | Status |
|---|---|---|
| #244 | The whole work item — the spec chain (this item iterates its outer loop on a PR) and the implementation. | open |
Progress entries
2026-08-16 16:30 UTC — the graph had to be entered by hand again
- Phase: phase-selection
- Did: found the work item parked at
phase-selectionwith its checklist never posted — the same spawn-time gap issue-238 hit and recorded (graphlink._guardedrefuses to enter the graph untildocs/specs/<id>/exists, andgraph run→advanceevaluates the current node's exit chain rather than running an entry chain). Scaffoldeddocs/specs/issue-239/execution-log.md, then entered the node by callingRuntime.start("issue-239", ref=…)throughcore.graphs._runtime— the same code pathgraphlink.on_spawnuses. The entry chain ran:loop:phase-selectionapplied and the phase-selection checklist posted (comment). - Checkpoint/tests:
the-loop graph status issue-239→at phase-selection, waiting. - Next: wait for an authorized user's
the-loop executeto freeze the phase selection and the outer-loop surface, then deriverequirements.md. - Blockers: the phase-selection gate — a human act the session never answers itself.
2026-08-16 16:36 UTC — phases frozen; requirements drafted
- Phase: requirements-definition
- Did: @MadaraUchiha-314 replied
the-loop executewithbrainstorming,requirements-approvalandcritic-reviewunticked andouter-loop-on-pull-requestticked (comment). Read the refresh path end to end before writing anything —useControlPlane's two rounds,HttpApi, the/api/v1router,eventlog's JSONL contract andEVENT_TYPES, the Settings page, and the detail page's trace/chat-bar layout. Wroterequirements.md: 6 requirements, the load-bearing one being that a stream which only replays/api/v1/eventsdoes not refresh the board, because loop position comes from round two (graph/checkovergraph-state.json) and is not in the event log's shape. - Checkpoint/tests:
make lint→ ruff clean, markdownlint 691 files, 0 errors. - Next: derive
design.md— settle SSE vs WebSocket against the constraints requirements fixed, then the testing plan; both land at thedesign-approvalgate. - Blockers: none.
2026-08-16 16:52 UTC — design and testing plan locked; at the first human gate
- Phase: design-approval
- Did: wrote
design.md, two self-contained UI prototypes underdesign/, andtesting-plan.md; locked all three and advanced the graph twice. The design settles the ticket's open question — SSE, not WebSocket — on the ground that a WebSocket handshake is exempt from CORS, so choosing it would silently drop a boundary the REST surface already has. Two further findings came out of writing it: the stream must never carryapi.request(the control plane's own refresh would feed itself forever), and one invalidation class would make streaming more expensive than the 15s poll it replaces. - Checkpoint/tests:
make lint→ 0 errors over 693 files.the-loop graph completepasseddesign(validate-artifacts + enforces-boundaries-from + lint) andtest-planning. - Next: wait for @MadaraUchiha-314 at
design-approval, which readsdesign.mdandtesting-plan.mdtogether. Thentasks-breakdown. - Blockers: the
design-approvalgate. - Observed, not fixed:
request-reviewposted its gate notice on the ticket, not on PR #244, although this work item's frozen surface ispull-request. The artifacts and their review belong on the PR, so the briefing was posted there by hand. Out of scope here; worth its own ticket.
2026-08-16 17:20 UTC — design approved; task DAG written
- Phase: tasks-breakdown
- Did: @MadaraUchiha-314 approved at
design-approvalwith a bareapproved(comment), so all four design open questions stand as written — SSE, the transcript in scope,streamas the default mode, and Requirement 6 staying in this item. Wrotetasks.md: 16 tasks in two chains that share no file, plus Requirement 6 standing alone, meeting only at the documentation task. - Checkpoint/tests:
make lint→ 0 errors over 694 files, after the detour below. - Next: implementation. Entering it crosses the phase boundary, so the context is cleared and the work re-enters from the locked artifacts on disk (
contextManagement.phaseBoundary: clear). Start at tasks 1, 2, 8, 9, 10 and 15 — the six roots. - Blockers: none.
- Detour, ticketed not fixed:
record-feedbackwrote the approval as**@handle**alone on a line, which markdownlint's MD036 rejects — so the gate's own hook leftdesign.mdandtesting-plan.mdfailing this repo's lint. Reflowed both blocks by hand to**@handle** — approved(attribution and body preserved verbatim) and filed #247. Not fixed inside this PR: it is an unrelated harness change, and every change is a work item with a ticket.
2026-08-16 17:35 UTC — task 1 done: service.stream config
- Phase: implementation
- Did:
service.stream(enabled,maxSubscribers,keepAliveSeconds) in both copies ofcli-config.schema.json— they are byte-compared bytest_config_schema_parity.py, socpis the only correct way to move one — plusstream_config()inapi/config.pyand the three leaves documented indocs/config/cli/service-options.md(test_docs_parity.pyP4 gates that). The cap clamps up to 1 rather than raising: a 0 would refuse every connection and an "unlimited" fallback would hand abuse case 1 a configuration switch. - Checkpoint/tests: red →
ImportError: cannot import name 'DEFAULT_STREAM_MAX_SUBSCRIBERS'; green → 9 passed intest_api_stream.py, 37 passed across the parity tests. - Next: task 2 — the log tailer and the cursor, in a new
cli/the_loop/api/stream.py. - Blockers: none.
- Noted: four tests fail on
origin/mainunchanged by this work —test_core_repo.py::test_critics_lists_configured_entries_without_argv,test_critics.py::test_list_reports_availability,test_harness_gate.py::…does_not_escape_the_temp_dir,test_poll_daemon_integration.py::test_start_detaches_a_poller…. Confirmed pre-existing by running them on a stashed tree. Not this work item's to fix; recorded so the verification evidence is not read as a regression.
2026-08-16 18:05 UTC — tasks 2-7 done: the service serves the stream
- Phase: implementation
- Did:
cli/the_loop/api/stream.py(tailer, cursor, broker, transcript watch, the SSE generator), theGET /api/v1/streamroute, fourEVENT_TYPES, and the endpoint in the OpenAPI contract.core.sessions.get_transcriptwas split so the path derivation — and every fail-closed refusal issue-209 wrote — is reused by the watch rather than copied into it. - Checkpoint/tests: red on every task before green.
test_api_stream.py31 passed,test_stream_integration.py14 passed (16s), full Python suite 2148 passed.make lintclean,make formatapplied. - Next: the control-plane chain — tasks 8, 9, 10 are three independent roots.
- Blockers: none.
- Three findings worth the reviewer's time:
- The testing plan was wrong about T3 and is replanned. Starlette's
TestClientcollects the whole response body before returning, so against an endlesstext/event-streamit never returns — the first draft of the integration file hung for five minutes. The suite now boots real uvicorn on an ephemeral port. That is a better test, not a workaround: headers-before-body, one-frame-at-a-time and refuse-while-held are all invisible to a buffering client. - Two of those tests were briefly vacuous.
eventlog.emitis a module-level no-op untilconfigureis called, and onlyserve.maincalls it — so theapi.requestexclusion test was asserting against an empty log. The fixture now configures the log asserve.maindoes, and the test asserts the records were really written before asserting they were excluded. Request.is_disconnectedwas removed from the stream loop. It reads the same ASGIreceivechannel Starlette's ownlisten_for_disconnectconsumes for aStreamingResponse. It was not the cause of the symptom I first blamed it for (that was a test abandoningiter_text()mid-iteration, which closes the connection), but two consumers of one channel is a race worth not having.
- The testing plan was wrong about T3 and is replanned. Starlette's
- Known pre-existing failures, ticketed not fixed: five tests fail on this tree. Four fail identically on
origin/main(test_core_repo,test_critics,test_harness_gate,test_poll_daemon_integration). The fifth,test_control_integration::test_a_labelled_work_item_does_not_spawn_until_it_is_started, is load-flaky: it waits on the spawn and asserts on the registration. Isolated it passed 15/15; after a file that does nothing but burn 16 seconds of wall-clock — no the-loop code involved — it failed 1/6. Filed as #251.
2026-08-16 18:25 UTC — tasks 8-15 done: the control plane consumes the stream
- Phase: implementation
- Did:
refreshModewith its v1 migration, the invalidation map,stream()on both clients,useStream, the mode-drivenuseControlPlanewith a targeted graph refresh, the three-mode Settings card, the connection chip in the nav, the live transcript, and Requirement 6's sticky chat bar and scrolling trace. - Checkpoint/tests:
bun run lintclean,bun run typecheckclean,bun run test139 passed (10 files),bun run buildclean. - Next: task 16 — capability docs, user-facing docs, the decision record.
- Blockers: none.
- Two decisions a reviewer should check:
- The broker is owned by the router, not the lifespan — a deviation from
design.md§ Components, made while wiring it. The router is the only thing that travels into an embedder's application (issue-212 R3.3), so a lifespan-owned broker would simply not exist for SDK consumers and the stream would 500 for them. It costs nothing: the tailer task starts with the first subscriber and stops with the last. - The stream connection lives in
App, not in the detail page. R3.5 allows one connection per tab; a page-owned connection would open and close on every navigation and replay a cursor for nothing. The viewed ref travels down as atranscriptwatch and the news comes back astranscriptTick.
- The broker is owned by the router, not the lifespan — a deviation from
- One bug caught by writing a test for it: while satisfying oxlint I rewrote the SSE listeners and made the transcript handler read
work_item— what an event-log record carries — where the service sendsref. Typecheck and lint were both green, because these are strings on an untyped payload.client.test.tsnow puts a real frame of each kind through the decoder; that suite did not exist before, which is why the bug was possible.
2026-08-16 18:40 UTC — task 16 done: docs, capability docs, decision record
- Phase: implementation → verification
- Did:
decision-087(SSE over WebSocket, with the CORS asymmetry as the deciding reason) and its index row; seven behaviours added to thecontrol-planecapability doc and the four event types toobservability; theservice.streamleaves documented in the config reference; a Keeping the screen current section inui/README.md. The execution log's gated## Capability docsand## Documentationsections are filled, including the reasonREADME.mdand the skill needed no change. - Checkpoint/tests:
make lintclean over 696 files; the three parity tests (docs_parity,api_contract_parity,config_schema_parity) pass — they are what would have caught an undocumented schema leaf or a contract that drifted. - Next: the
verificationnode — executetesting-plan.mdand record evidence. - Blockers: none.
Verification results
Only when this work item declared
test-planningaway (issue-179). With atesting-plan.mdtheverificationnode records its results there, against the matrix rows it planned, and this section stays as the template left it. Without one, this is where the proof lives — andverificationblocks until it is filled in, because skipping the plan removes the document, never the verifying.
| What was verified | Command | Outcome | Evidence |
|---|---|---|---|
| pass | fail | link or evidence/<file> |
2026-08-16 18:55 UTC — verification: everything that could run, ran
- Phase: verification
- Did: executed
testing-plan.md. The automated rows all pass. For the live rows I stood up a service built from this branch on a seeded state root whose registered session is this conversation, so the transcript watch pointed at a file genuinely growing during the run — and held the stream open withcurl -Nwhile another process appended records. Three things fell out of one capture: records arriving with byte-offset ids, a realtranscriptframe (1720 lines of this conversation), and noapi.requestframes despite the API traffic that produced them. Also proved live: the503atmaxSubscribers, and a second service with the stream disabled answering404while healthy. - Checkpoint/tests: Python 2148 passed / 5 pre-existing failures; UI 144 passed, lint, typecheck and build clean; the OpenAPI, schema-parity and docs-parity gates all pass; all 13 Gherkin scenarios registered.
- Next:
self-review(3 rounds —critic-reviewwas declared skipped), then the security review, which needs a named human sign-off at risk tier 4. - Blockers: none blocking, one escalation open — see below.
- Four activities did not run, and are not ticked. Everything needing a browser: T6's screenshots, T10's keyboard/screen-reader pass, and the browser halves of T12 and T13. This session has no working browser connection (the Chrome extension reports not connected); the service and the Vite dev server both came up, so the gap is exactly the browser. Replanned rather than dropped: the behavioural half of T6 is now an automated test that did run (
WorkItemDetail.test.tsx). What stays unverified is rendering — jsdom applies no stylesheet, somax-height,overflow-yandposition: stickyare inert — plus the visual connection states. Escalated on PR #244. - One contract defect found by reading the evidence, not by a test. The first version of the OpenAPI block offered both
text/event-streamandapplication/jsonon the 200: FastAPI infers a JSON response from the return annotation and merges it with a declared one. The parity test compares paths, methods and operation ids, so both documents were equally wrong and it passed. Fixed withresponse_class=StreamingResponse.
2026-08-16 19:05 UTC — the browser rows ran after all, and found a bug
- Phase: verification
- Did: @MadaraUchiha-314 asked why this needed a human. Fair question, and the answer was that I had stopped at the wrong obstacle: the Chrome extension is not connected (
list_connected_browsers→[]), but Chrome itself is installed andbunspeaks WebSocket, so a ~200-line scratchpad script launches headless Chrome with--remote-debugging-portand drives it over CDP. No repository dependency; nothing added toui/package.json. All four browser rows executed against the real bundle and a real service. - Checkpoint/tests: T6, T10, T12 (browser) and T13 (browser) now ticked with evidence in
evidence/browser.mdand six screenshots underevidence/ui/. - Next:
the-loop graph complete issue-239→ the review chain and the security gate. - Blockers: none. The escalation on PR #244 is withdrawn.
- T13 found a real defect — the point of the row.
EventSourceretries a dropped connection, but a response it will not accept — a 404 from a service too old for the route — is terminal: the browser closes the source and never retries.useStreamwas counting to five consecutive failures, so against a 404 it received exactly one and sat onstream · reconnecting (1)with a frozen board forever. That is the precise state R4.1 exists to prevent, reached through the mechanism meant to prevent it. No stub would have found it — every stub retries politely. The transport now reports whether the browser gave up, and a terminal failure short-circuits the count. - What the browser proved that nothing else could: with the page idle in streaming mode, zero requests in three seconds; then another process appended one record and the page refreshed 283ms later, against a 15-second poll. And R1.6 — CORS parity — which the plan had written off as unautomatable: a real browser opened
EventSourcecross-origin and the service accepted it, because the origin is inservice.cors.allowOrigins.httpxcannot show that; it does not enforce CORS at all. - Also this window: main advanced under this branch (issue-243 merged) and a merge was in flight with two conflicts. Resolved —
uv.locktaken fromorigin/main, and this work item's decision record renumbered 086 → 087 because issue-243 had claimed 086. Two more hook-generated headings failed markdownlint (MD024, repeatedentry <node>headings fromlog-entryre-entries); disambiguated in place and added to #247, which is the same root cause.
2026-08-16 19:20 UTC — CI fixed: three tests that waited on the wrong signal
- Phase: needs-review
- Did: @MadaraUchiha-314 asked for CI to be fixed. One job was red:
test_tmux_runner_integration::test_legacy_record_without_a_tmux_target_heals_via_respawn, onassert "d-legacy-1" in healed.recent_deliveries→[]. - Diagnosed before touching anything. Every file that test exercises —
runner.py,webhook/dispatcher.py,sessions/registry.py,poller/poller.pyand the test itself — is byte-identical toorigin/mainon this branch, so the code under test is unchanged and only the wall-clock differs. It is the #251 shape a third time: the test waits for the tmux target to be written and then asserts onrecent_deliveries, which the dispatcher writes in a later step on its own thread. - Fixed all three, not just the red one. Each now waits on everything it asserts rather than on one signal that precedes the rest — the remedy #251 already prescribed. This is that ticket's fix landing here rather than separately, because a red CI on this PR is this PR's problem whoever's defect it is; #251 stays open for the tests to be swept for the same pattern.
- Checkpoint/tests: the three files 71 passed; full Python suite 2216 passed with the same four macOS-local failures that fail on
origin/main(they pass in CI, whose environment they are sensitive to);make lint,format-checkandtypecheckclean. - Next: the security review — risk tier 4, so a named human sign-off.
- Blockers: none.
Design critic review
Only when this work item selected the opt-in
design-critic-reviewphase (issue-188) — a different model/harness reading the lockeddesign.mdagainst the requirements, before the testing plan and the task DAG are derived from it. The node blocks until this section is filled in; a work item that did not select the phase leaves it as the template left it. Rounds followreference/reviewing.mdunchanged: attribution prefix, own-comment marker, reply-first-then-fix, stop on zero new findings, escalate on a repeated finding. A round that could not run is recorded asunavailablewith the cause and does NOT count towardreviews.criticReviewCount.
| Round | Critic (<harness>/<model>) | Outcome | Findings → disposition | Link |
|---|---|---|---|---|
| new findings | zero (converged) | escalated | unavailable |
Review cycles
Outcome is one of: new findings · zero (converged) · escalated · unavailable (the configured critic could not run — it does NOT count toward
reviews.criticReviewCount).
| Cycle | Type (self/critic/security) | Reviewer | Outcome | Link |
|---|---|---|---|---|
| 1 | self | the-loop (this session) | new findings — 5, all fixed with a failing test first | this log, below |
| 2 | self | the-loop (this session) | new findings — 2, both in the control plane, which round 1 had not read | this log, below |
| 3 | self (security lens) | the-loop (this session) | new findings — 2, both amplification | this log, below |
critic-reviewwas declared skipped atphase-selection, so the self-review rounds are the whole automated review chain.
Round 1 — five findings
Each was written as a failing test before the fix, so each is now something that cannot silently come back.
- A recovered subscriber could never be desynced again.
Subscriber.desyncedwas set on the first overflow and never cleared, so a client that caught up and later fell behind again lost frames silently — believing it was current when it was not, which is the exact failure the whole design is against. The flag now means "a resync is waiting", cleared on the next successful read. - The partial-line buffer was unbounded. The subscriber queues are bounded (abuse case 5); this was the other buffer, and a writer that never terminated a line — or a corrupted file with no newline — would have grown it until the process died. Bounded at
MAX_PARTIAL_BYTES(1 MiB), with the remainder dropped and the next newline resynchronising. - A record appended during replay could be lost.
serve()read the log to EOF, then started the tailer withseek_to_end()— leaving a gap the width of however long the replay took. A dropped record is precisely whatLast-Event-IDexists to prevent. The tailer is now positioned when the first subscriber registers, and the replay window ends at exactly that offset: no gap, and no overlap either. - The transcript line count re-read the whole file every tick. An agent's JSONL reaches megabytes; counting it from scratch twice a second for a number that moves by a handful is real I/O. The count is now carried and only the appended bytes are counted (falling back to a full count if the file shrank).
- A lockfile downgrade had been committed.
uv.lockwentrevision = 3→2, because this machine'suvis older than the one that wrote it. Nothing to do with this work item and capable of confusing everyone else; reverted toorigin/main's copy.
Two consequences worth naming, because they change observable behaviour:
- The stream now carries its own
stream.subscribed. Positioning the tail at registration means a connection sees the record of its own arrival. That is correct — it is an event on the workstation like any other, and excluding it would be special-casing — but it cost one over-specified assertion in four tests, which asserted on the first frame rather than on the frame they cared about. Fixed in the tests, not by hiding the event. design.md§ Components said the broker is started from the lifespan. It is started from the router instead, for the reason recorded at task 8-15's checkpoint: the router is the only thing that travels into an embedder's application. The design's table row now says so.
Round 2 — two findings, both in the control plane
Round 1 read the service and never opened the browser half. Both of these are the same class of defect — a stale answer landing on top of a fresh one — and neither is visible except under a burst, which is exactly when streaming is on.
- A targeted graph refresh could overwrite fresher rows.
refreshGraphcreated anAbortControllerand dropped it, so a slowgraph/checkcould land after a full reload and merge a staler report over what the reload had just drawn. It is now aborted by the next full load and on unmount, like round one already was. - Two coalesced flushes could lose one another's report.
refreshGraphread the held reports before itsawaitand wrote the merge afterwards — a read-modify-write, and two flushes in flight at once meant the second write dropped the first one's result. It now merges against what is held at write time, through an exportedmergeReportswhose test pins that it mutates neither side.
Round 3 — two findings, read with the security lens
Both are the same shape, and it is the shape reviews.selfReviewCount: 3 exists to catch: a bound that was assumed rather than written. The design bounded what a subscriber can buffer (the queue) and how far it can replay; neither of these was bounded at all.
- A request could ask to watch an unbounded number of refs.
?transcript=is a session-registry read plus astatevery tick, per ref, and?workItem=is a comparison per record per ref. Nothing capped the list, so one cheap request bought work repeated twice a second for as long as the connection was held — an amplification themaxSubscribersbound does nothing about, because it is one connection. Capped atMAX_FILTER_ENTRIES(64, comfortably more than any board has rows), refused with400naming the limit. - A refusal recorded an unbounded amount of the caller's own text.
stream.refusedquotes the parse error, which quotes the ref. The event log is append-only and read by people, so what a stranger can write into it per request now has a ceiling (MAX_REFUSAL_DETAIL). The400sent back to the caller is untruncated — they already know what they sent.
Zero further findings on a re-read of the same surface, so the self-review loop stops here per reviews.stopOnNoNewFindings. critic-review was declared skipped, so the next gate is the security review — which at risk tier 4 needs a named human sign-off.
A second flaky test, same family as #251:test_webhook_routing_integration::test_spawning_for_a_linked_issue_records_the_binding failed once in a full-suite run and not in the next; alone it passed 6/6. Added to that ticket rather than filed again.
Security review (gate)
Required before ready-to-ship (
security.review.required). Seereference/security.md.
- Mechanism: <security-review skill | the-loop checklist> (
security.review.mechanism) - Outcome: <pass | findings fixed (link threads) | escalated>
- Human sign-off: <n/a (tier below
security.review.humanSignOffMinTier) | @handle + link>
Final validation evidence
The evidence presented to the user proving acceptance criteria are met. Summarised from testing-plan.md's Verification results (the verification node produced the raw record — command, outcome, committed evidence per activity); this section maps it onto the acceptance criteria rather than re-deriving it. Committed evidence files live under <specDir>/<id>/evidence/.
Capability docs
Which living capability docs this work item changed, and the history row that traces each behaviour back to it. Capability docs are the organized view of specs — the single source of truth for a capability's current behaviour — so they are updated in the same PR as the change (
workflow.capabilitiesDir), and this section is what thecapability-docsnode gates on. A work item that genuinely changed no capability says so here, and why; the section is never deleted to shorten the log.
| Capability doc | What changed | History row |
|---|---|---|
control-plane.md | Seven behaviours added: the stream as a read surface and its three frame kinds; the api.request/mcp.call exclusion with no opt-in; lossless resume with a bounded replay; the subscriber bound and the shared tailer; the viewer's three refresh modes and their migration; and visible degradation with the two invalidation classes. | issue-239, linking the spec and decision-087 |
observability.md | The four new event types — stream.subscribed, stream.refused, stream.desync, stream.disconnected — and the note that api.request/mcp.call stay in the log while never reaching the stream. | issue-239 |
Documentation
Which user-facing documents this work item changed —
README.md, the documentation site underdocs/, and the operating-model skill with itsreference/docs. Capability docs above are the organized view of specs, written for a reader who already uses the project; this section is the surface a reader meets before that, and it rots the same way, so it is updated in the same PR as the change (reference/workflow.md, ready-to-ship gate). Thecapability-docsnode gates this section alongside the one above (issue-174).A work item that genuinely changed no user-facing documentation says so here with the reason — "internal refactor, no described behaviour changed" is an answer; a blank is not. The section is never deleted to shorten the log. A row names a document, never a token, a credential or an internal hostname: this tree is as public as the repository.
| Document | What changed |
|---|---|
docs/config/cli/service-options.md | A new The stream section documenting stream.enabled, stream.maxSubscribers and stream.keepAliveSeconds — required by test_docs_parity.py P4, which fails the build for an undocumented schema leaf. |
ui/README.md | A Keeping the screen current section: the three refresh modes and when each is right, what streaming actually refreshes, what the header says when it cannot connect, and that demo mode streams the viewer's own clicks rather than inventing traffic. |
docs/api-specs/openapi/the-loop.v1.yaml | streamEvents: the two query parameters, the text/event-stream response with its frame description and example, and the 400/404/503 answers. Contract-first, and gated by test_api_contract_parity.py. |
docs/decisions/decision-087.md | New — SSE over WebSocket, with the CORS asymmetry as the deciding reason, plus what it costs (one of the browser's six per-origin connections) and what it constrains. Indexed in decisions.md. |
No change was needed to README.md or to the operating-model skill: this work item changes what the control plane does, not how the loop is run, and neither document describes the dashboard's refresh behaviour.
2026-08-16 — entry phase-selection
- Node: phase-selection
- Boundary: entry
2026-08-16 — entry requirements-definition
- Node: requirements-definition
- Boundary: entry
2026-08-16 — entry design
- Node: design
- Boundary: entry
2026-08-16 — entry test-planning
- Node: test-planning
- Boundary: entry
2026-08-16 — entry tasks-breakdown
- Node: tasks-breakdown
- Boundary: entry
2026-08-16 — entry implementation
- Node: implementation
- Boundary: entry
2026-08-16 — entry verification
- Node: verification
- Boundary: entry
2026-08-16 — entry implementation (re-entered, 2)
- Node: implementation
- Boundary: entry
2026-08-16 — entry verification (re-entered, 2)
- Node: verification
- Boundary: entry
2026-08-16 — entry self-review
- Node: self-review
- Boundary: entry
2026-08-16 — entry security-review
- Node: security-review
- Boundary: entry