Skip to content

feat(roadmap): read-only epic status and ready work items (#333) - #356

Merged
mashkovd merged 5 commits into
mainfrom
feat/333-roadmap-read-api
Sep 23, 2026
Merged

mashkovd merged 5 commits into
mainfrom
feat/333-roadmap-read-api

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Closes #333.

mctl-api is a read-only consumer of the RoadmapPublication that mctlhq/.github publishes to its generated roadmap-state branch (.github#119/#121, epic metadata in .github#123). It never runs the evaluator, never infers readiness, never writes to GitHub.

What

  • internal/roadmap verifies a publication, then selects from it:

    • apiVersion/kind must be roadmap.mctl.ai/v1alpha1 / RoadmapPublication;
    • the sha256 of snapshot.json, ready-set.json and health.json must match publication.json;
    • exactly one ready set and one health document per named manifest, derived from that manifest's digest;
    • each epic carries identity and lifecycle (.github#123);
    • a live capture carries capturedAt;
    • the ready list agrees with the item states, and states come from the evaluator's closed vocabulary.

    Anything that fails any of these is refused (ErrUnavailable → 503). A cached publication is served only for the commit it was read at, never in place of a broken newer one.

  • gitops.Reader gains Revision() and ReadFiles(), which read all files and HEAD under one read lock, so they come from one checkout. cmd/api starts a second Reader on https://github.com/mctlhq/.github.git@roadmap-state (public, no credential), refreshed every 5 min. ROADMAP_STATE_* env, including a kill switch.

  • REST, authenticated, outside the write group:

    • GET /api/v1/roadmap/epics
    • GET /api/v1/roadmap/epic-status?epic=<name | owner/repo#N>: the published RoadmapReadySet and RoadmapHealth for the epic, byte for byte, plus lifecycle, title, goal and manifest.
    • GET /api/v1/roadmap/ready?epic=&required_only= (default true): the items the published ready list names. Without epic, every lifecycle: active epic.
  • MCP: mctl_get_epic_status, mctl_get_ready_work_items, both read-only.

    • Portal allowlist entries are enabled: false: exposing them on the shared portal is an owner decision.
  • Provenance on every answer: state_revision (roadmap-state commit), evaluator_revision, source (repository, ref, revision), observation (mode, capturedAt, apiBase) and age_seconds. A client judges freshness itself; 503 means "cannot say", never "nothing is ready".

Acceptance criteria

Tests

  • internal/roadmap/testdata/live is a real publication copied byte for byte from roadmap-state@a36af84 (verified with publish.py verify). Tests compare against those bytes and never restate a readiness of their own.
  • Tamper table (11 cases) → ErrUnavailable.
  • Reader cache and no-stale test; handler tests for 503 (unconfigured or broken), 400, 404 and the required_only default; ReadFiles test against a real git checkout; kill-switch test.
  • Mutation proof: 16 mutants, all killed:
    • dropping the digest, apiVersion, manifest-sha, leftover-epic, capturedAt, epic-lifecycle, ready-list, state-vocabulary checks;
    • the active-lifecycle filter, the required_only filter and the ready-state filter;
    • the revision-keyed cache;
    • lookup by root issue;
    • the age computation;
    • the handler's required_only default.
  • go test -p 1 ./... with Postgres: green.

Note: internal/openapi/openapi.yaml on main does not parse (line ~2498); #354 fixes it and adds TestSpecParses. The roadmap paths added here parse once that line is fixed; checked locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G

mctl-api reads the RoadmapPublication that mctlhq/.github publishes to its
roadmap-state branch and serves it; it never evaluates readiness.

- internal/roadmap: verifies the publication (apiVersion, sha256 of every
  derived file, one ready set and one health per named manifest, ready list
  agreeing with item states) and selects from it. Anything unverified is
  refused; a failed publication is never replaced by a cached one.
- gitops.Reader: Revision and ReadFiles, one consistent checkout per read.
- REST: GET /api/v1/roadmap/{epics,epic-status,ready}; 503 means cannot say.
- MCP: mctl_get_epic_status, mctl_get_ready_work_items (portal: disabled,
  pending an owner decision).
- Every answer carries provenance: state/evaluator/source revisions, the
  capture time and its age.
- Tests read a real publication copied byte for byte (testdata/live).

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
Copilot AI lite review requested due to automatic review settings September 23, 2026 09:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 7m 30s —— View job


Code review complete

  • Read the pinned diff (2557a89...a36ebcc)
  • Review internal/roadmap verification logic
  • Review gitops Reader.Revision/ReadFiles (locking)
  • Review REST handlers + router (auth placement)
  • Review MCP tools + cmd/api wiring + helm/env
  • Post inline findings and submit verdict

Verdict: changes requested — 0 P1, 2 P2, 3 P3. Findings are posted as inline comments on the diff.

What holds up

The verification core in internal/roadmap/roadmap.go is well built. Digest gating against publication.json, the strict manifest ↔ ready-set ↔ health bijection (including the leftover check at roadmap.go:319), the ready list vs. per-item state cross-check in parseItems, and the revision-keyed cache that refuses to serve a stale publication in place of a broken newer one are all correct. The tamper table and the byte-for-byte fixture comparisons are the right shape for this — the tests assert against published bytes rather than restating a readiness of their own.

Reader.Revision() and Reader.ReadFiles() take r.mu.RLock() and call revisionLocked()/redactTokenLocked under it, so the "files and revision are one checkout" claim holds and there is no race with refresh(), which holds the write lock for its full duration. The routes sit inside the authenticated group and outside the 20/min write group, which matches the stated intent. Nil *roadmap.Reader is handled by the nil-receiver check in Current() and is tested.

P2 (blocking)

  1. internal/mcp/roadmap.go:75 — required_only declared as a string. stringArg type-asserts to string, so a client sending the JSON boolean false gets "", the parameter is dropped, and the REST default (true) applies. The caller asked for optional items and silently receives required-only ones. use_temporal already uses mcplib.WithBoolean for this reason.
  2. internal/mcp/roadmap.go:79 — no handler tests for either new tool. annotations_test.go covers hints and the tool count only. Query-string construction, # encoding in mctlhq/.github#57, and the 503-vs-404 distinction the tool descriptions promise are all unpinned.

P3 (non-blocking)

  • internal/api/handlers_roadmap.go:44 — the 503 body is err.Error() verbatim, exposing local paths and git stderr; the detail is already logged.
  • internal/roadmap/roadmap.go:482 — no negative cache: a failed Parse leaves the old-revision cache, so every request repeats the four-file read, three sha256 sums and parse for as long as the branch is broken. Also one git rev-parse fork per request, two on a cache miss.
  • internal/roadmap/roadmap.go:407 — epic name matches case-sensitively, root issue case-insensitively.
  • internal/gitops/reader.go:793 — the name guard blocks separators but os.ReadFile still follows symlinks inside the checkout; low impact here, but ReadFiles is generic and the other Reader points at a PR-fed repo.

Checked and not flagged

  • MCP tool count: server_test.go compares against len(recordedHints), which was updated; portal_allowlist_test.go requires an entry per tool, and both were added with enabled: false.
  • Fixture sizes are ~260 KB total, so the 256Mi memory limit and the /tmp checkout (the chart has no readOnlyRootFilesystem) are not a concern.
  • ghtoken.Static("") returns nil, so the public-repo path takes the no-credential branch cleanly.
  • The OpenAPI additions are well-formed in context; the pre-existing parse failure on main is out of scope here.

Caveat: go build, go vet and go test were blocked in this environment, so this is a static review. CI results should be checked before merging.
· Branch feat/333-roadmap-read-api

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit a36ebcc.

Findings

[P3] Missing 200 OK test coverage for GET /api/v1/roadmap/epics in handlers_roadmap_test.go

  • File: internal/api/handlers_roadmap_test.go (~line 83)
  • Anchor: +func TestRoadmap_EpicStatusAndReady(t *testing.T) {
  • Concrete failure scenario: While TestRoadmap_UnconfiguredOrBrokenAnswers503 tests the 503 fallback for /api/v1/roadmap/epics, TestRoadmap_EpicStatusAndReady tests the 200 OK response structures only for /api/v1/roadmap/epic-status and /api/v1/roadmap/ready. There is no test visible in this diff verifying that GET /api/v1/roadmap/epics successfully returns HTTP 200 OK with the expected "epics" list and "provenance" payload when backed by live data. If a regression in ListRoadmapEpics response shaping or serialization occurs, the test suite in this file would not catch it.
  • Proposed fix: In internal/api/handlers_roadmap_test.go, add an assertion in TestRoadmap_EpicStatusAndReady (or a dedicated test function) that issues GET /api/v1/roadmap/epics against liveRoadmapHandlers(), verifies an HTTP 200 status code, and checks that body["epics"] is a non-empty array and body["provenance"] is populated.

…o range copy

Also accept "f" as false in the kill switch, matching #354.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 6m 20s —— View job


Code review complete

  • Read the pinned diff (2557a89...657e53f)
  • Review internal/roadmap verification logic
  • Review gitops Reader changes (concurrency)
  • Review REST handlers + router (auth placement)
  • Review MCP tools + main.go wiring
  • Post inline findings and submit verdict

Verdict: changes requested — 1 P2, 6 P3. Findings are posted inline on the pinned commit.

P2

Current() forks a git process on every request, including cache hits — internal/roadmap/roadmap.go:482
r.src.Revision() runs unconditionally before the mutex and the r.cached.revision == rev check, and gitops.Reader.Revision() is exec.Command("git", "-C", path, "rev-parse", "HEAD"). Every roadmap REST request — and every mctl_get_epic_status / mctl_get_ready_work_items call, which routes through the same handler — spawns a subprocess even when the verified publication is already in memory. With the 300 req/min read budget and agent-polling clients, that is a steady few-hundred-fork-per-minute stream, each fork holding the gitops RLock. refresh() already runs rev-parse --short HEAD under the write lock; recording the full HEAD there makes the cache-hit path one mutex.

P3

Finding Location
An empty manifests[].sha256 passes verification and is served as provenance (the file-digest loop refuses an empty want; this loop does not) internal/roadmap/roadmap.go:282
Lookup is case-insensitive by root issue (EqualFold) but case-sensitive by name; the test pins only the forgiving half internal/roadmap/roadmap.go:407
The 503 body returns raw err.Error(), carrying ROADMAP_STATE_LOCAL_PATH and git stderr to the client and into the MCP tool result internal/api/handlers_roadmap.go:44
apiGet collapses 404 and 503 into one tool error, dropping the status and the code field — the apiGetStatus precedent (server.go:2547) exists for exactly this distinction internal/mcp/roadmap.go:53
required_only declared as a string: a JSON boolean fails stringArg's assertion and silently falls back to true, inverting the request internal/mcp/roadmap.go:75
No 200 test for GET /api/v1/roadmap/epics (also raised by the Antigravity pass) internal/api/handlers_roadmap_test.go:83

Fix the P2 and P3 findings →

What holds up

The verification chain is the strong part of this PR: apiVersion/kind pinning, the sha256 check against publication.json, the one-ready-set-and-one-health-per-manifest invariant, the leftover-epic check, and the ready list cross-checked against the items' own states from a closed vocabulary. The 11-case tamper table exercises each of them, and the fixture being a real publication copied byte for byte means the tests never restate a readiness of their own. ReadFiles correctly takes the files and HEAD under one read lock, so a refresh cannot land between them; the revision-keyed cache genuinely refuses to serve a stale publication in place of a broken newer one, and TestReaderReverifiesOnlyWhenTheCheckoutMovesAndNeverServesStale pins that. Routes sit inside AuthMiddleware and outside the 20/min write group, which matches the stated intent. killSwitchOn fails toward off. ghtoken.Static("") returns a nil Source and Resolve(nil) returns an empty token, so the no-credential path for the public repo works and the token-scrubbing defer is correctly skipped. The MCP tool count assertion derives from recordedHints, which this PR updates, so no separate server_test.go change is needed.

Note: I could not run go build or go test in this environment (command approval), so the review is static.
· branch feat/333-roadmap-read-api

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

agy returned a result this workflow could not use; see the workflow log. Treated as a failed review.

Comment thread internal/mcp/roadmap.go Outdated
mcplib.WithString("epic",
mcplib.Description(`Epic name or root issue ("mctlhq/.github#42"). Omit for every active epic.`),
),
mcplib.WithString("required_only",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — required_only is declared as a string, so a boolean argument is silently dropped and the answer flips to the opposite of what was asked.

stringArg (internal/mcp/server.go:2640) type-asserts to string. A client that sends the JSON boolean false — which is what an LLM will often emit for a parameter whose description reads "false" to include optional items. Defaults to true. — gets "" from stringArg, line 84 skips q.Set, and the REST handler applies its required_only=true default. The caller asked for optional items, receives only required ones, and gets no error: the ready list simply looks shorter than it is. use_temporal (internal/mcp/server.go:3030) already uses mcplib.WithBoolean for exactly this reason.

Either declare it with WithBoolean and read it with a bool-aware accessor, or accept both shapes in the handler (switch v := req.GetArguments()["required_only"].(type)) so a boolean is not silently discarded.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9bbb1: declared with WithBoolean; the handler takes a bool, still accepts a string, and refuses anything else instead of dropping it (TestGetReadyWorkItemsToolBuildsTheQuery; fails with the bool arm removed).

Comment thread internal/mcp/roadmap.go
mcplib.Description(`"false" to include optional items. Defaults to true.`),
),
)
handler := func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — neither MCP handler has a test.

internal/mcp/ ships handler tests for its tools in server_test.go (httptest backend, assert method + path + body). The two tools added here are covered only by annotations_test.go, which checks the hint map and the tool count — it never invokes the handlers. Nothing pins:

  • that mctl_get_ready_work_items builds /api/v1/roadmap/ready?epic=…&required_only=… and omits each parameter when blank (the bug in the comment above would be caught here);
  • that mctl_get_epic_status URL-encodes # in mctlhq/.github#57 rather than truncating the query at the fragment;
  • that a 503 from the REST side surfaces as a tool error whose text still says the publication is unavailable, which is the whole "cannot say" != "nothing is ready" contract these tools advertise in their descriptions.

A TestGetReadyWorkItemsTool / TestGetEpicStatusTool pair in the style of the existing tool tests would cover all three.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in fd9bbb1: internal/mcp/roadmap_test.go covers query building and omission, '#' encoding, and 503 vs 404 (mutation-checked).

Comment thread internal/api/handlers_roadmap.go Outdated
pub, err := h.opts.Roadmap.Current()
if err != nil {
slog.Warn("roadmap publication unavailable", "error", err)
writeErrorCode(w, http.StatusServiceUnavailable, roadmapCodeUnavailable, err.Error(), nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the 503 body is err.Error() verbatim, which carries internal detail out to the client.

Current() wraps whatever the checkout returned, so this can serve roadmap publication unavailable: roadmap-state checkout: open /tmp/roadmap-state/publication.json: no such file or directory or raw git rev-parse stderr. The token itself is safe (redactTokenLocked runs inside gitOutput), so this is disclosure of local paths and git internals rather than a credential leak, and the audience is authenticated. Still, the caller only needs to know that no publication could be verified; the detail already goes to slog.Warn on the line above. A fixed message plus the logged detail would keep the contract intact.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9bbb1: fixed 503 message, detail only in the log; the 503 test asserts no path leaks.

if r == nil || r.src == nil {
return nil, unavailable("no roadmap-state source is configured")
}
rev, err := r.src.Revision()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — every request forks a git rev-parse, and a broken publication is re-read and re-verified on every request.

Revision() runs exec.Command("git", …) under the gitops read lock, so each /api/v1/roadmap/* call costs a process fork; a cache miss costs two, because ReadFiles calls revisionLocked() again. More notably there is no negative cache: when Parse fails, r.cached keeps the old (different-revision) publication, so the next call misses again and repeats the full four-file read, three sha256 sums and JSON parse. With the 300 req/min per-user limit, a publication that stays broken turns every roadmap call into that work for as long as the branch stays broken. Caching the failure alongside its revision — serve the stored ErrUnavailable until rev moves — would keep the "never serve stale" guarantee (which TestReaderReverifiesOnlyWhenTheCheckoutMovesAndNeverServesStale pins) while bounding the cost to once per revision.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9bbb1: HEAD is recorded at the end of refresh, so no fork per request; a failed verification is cached for its revision and re-read only when the checkout moves (reader test asserts reads=2 across two broken calls).

Comment thread internal/roadmap/roadmap.go Outdated
func (p *Publication) find(key string) (*epicEntry, error) {
key = strings.TrimSpace(key)
for _, e := range p.epics {
if e.epic.Name == key || (e.epic.Issue != nil && strings.EqualFold(e.epic.Issue.String(), key)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the two lookup keys disagree on case: the epic name matches exactly, the root issue matches case-insensitively.

epic=MCTLHQ/.github%2357 resolves (and TestAcceptanceExamplesFollowThePublication pins that it does), but epic=Lifecycle-Ownership is a 404. Callers copying an epic name out of a document with different casing get "epic not found" where the sibling key would have worked. Either fold both with strings.EqualFold or fold neither; the asymmetry is the part that will surprise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9bbb1: names fold case like root issues (test added).

Comment thread internal/gitops/reader.go Outdated
if name == "" || name != filepath.Base(name) || name == "." || name == ".." {
return nil, "", fmt.Errorf("invalid file name %q", name)
}
data, err := os.ReadFile(filepath.Join(r.localPath, name))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the name guard blocks path separators but not symlinks, and os.ReadFile follows them.

name != filepath.Base(name) correctly rejects ../x and .git/config (the test covers both), but a file inside the checkout that is a symlink still resolves wherever it points: a publication.json committed to roadmap-state as a symlink to a file outside the checkout is read and fed to json.Unmarshal. For the roadmap caller the blast radius is small — three of the four files are digest-gated, and whoever controls the branch could commit the content directly — but ReadFiles is a general-purpose method on Reader, and the other Reader in this process is pointed at mctl-gitops, whose contents arrive by PR. An os.Lstat check for fs.ModeSymlink before the read closes it in three lines and keeps the method safe for the next caller.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9bbb1: Lstat, only regular files are read; test with a symlink out of the checkout.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 0 P1, 2 P2, 3 P3.

The verification core in internal/roadmap is solid — digest gating, the manifest/ready-set/health bijection, the ready-list/state cross-check and the revision-keyed cache are all correct, and the tamper table plus the byte-for-byte fixture tests are the right way to pin them. ReadFiles/Revision take the read lock correctly, so the "one checkout" claim holds and there is no race with refresh().

P2: (1) required_only is declared as an MCP string, so a client sending the JSON boolean false has it silently dropped and receives required-only results — the opposite of what was asked, with no error. (2) Neither new MCP handler has a test; annotations_test.go only covers hints and the tool count, so query-string construction and the 503-vs-404 distinction are unpinned.

P3: internal error text in the 503 body; no negative cache on a failed Parse (plus a git rev-parse fork per request); asymmetric case handling between epic name and root issue; ReadFiles follows symlinks inside the checkout.

Note: I could not run go build/go test in this environment, so the review is static only.

if r == nil || r.src == nil {
return nil, unavailable("no roadmap-state source is configured")
}
rev, err := r.src.Revision()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Current() forks a git process on every request, including cache hits.

r.src.Revision() is called unconditionally before the mutex and before the r.cached.revision == rev check, and gitops.Reader.Revision() is exec.Command("git", "-C", path, "rev-parse", "HEAD"). So every GET /api/v1/roadmap/epics, /epic-status and /ready — and every mctl_get_epic_status / mctl_get_ready_work_items call, which routes through the same REST handler — spawns a subprocess even when the verified publication is already in memory and nothing has changed.

Failure scenario: the authenticated read group allows 300 req/min per user, and these tools are built for agent polling (the shepherd sweeper already polls /agents/dev-loop/<id> once per proposal per tick). A handful of polling clients becomes a steady few-hundred-fork-per-minute stream on the API pod, each fork holding the gitops RLock and contending with refresh(). No correctness impact, but it is entirely avoidable: refresh() already runs rev-parse --short HEAD at the end while holding the write lock. Record the full HEAD in a Reader field there and have Revision() / ReadFiles return it, so the cache-hit path costs one mutex instead of a fork+exec.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9bbb1, see above: Revision/ReadFiles return the HEAD refresh() recorded under the write lock.

}
names := map[string]bool{}
for _, m := range pub.Manifests {
if m.Epic == nil || m.Epic.Name == "" || m.Epic.Lifecycle == "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — an empty manifests[].sha256 passes verification and is served as provenance.

This loop requires m.Epic.Name and m.Epic.Lifecycle to be non-empty but never checks m.SHA256. Line 300 then compares doc.wire.Epic.Manifest.SHA256 != m.SHA256, which is satisfied when both sides are empty.

Failure scenario: a publication written with an empty (or omitted) sha256 on every manifest verifies cleanly, and /api/v1/roadmap/epic-status answers 200 with epic.manifest.sha256 empty. The acceptance criterion "provenance explains every answer: manifest path and sha256 per epic" is then satisfied only in shape. The file-digest loop above already guards this case for the three derived files by refusing an empty want; the same guard here would close it. (m.Path empty is already caught indirectly, since parseList refuses an empty manifest path and the lookup then misses.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9bbb1: a manifest without a digest is refused (tamper case added; fails with the check removed).

Comment thread internal/roadmap/roadmap.go Outdated
func (p *Publication) find(key string) (*epicEntry, error) {
key = strings.TrimSpace(key)
for _, e := range p.epics {
if e.epic.Name == key || (e.epic.Issue != nil && strings.EqualFold(e.epic.Issue.String(), key)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — lookup is case-insensitive by root issue but case-sensitive by name.

e.epic.Issue.String() is compared with strings.EqualFold, while e.epic.Name == key is exact. TestAcceptanceExamplesFollowThePublication pins the forgiving half (MCTLHQ/.github#57 resolves), so a caller reasonably infers the same tolerance applies to the other argument form.

Failure scenario: an MCP client that title-cases or upper-cases an epic name — Lifecycle-Ownership instead of lifecycle-ownership — gets 404 epic_not_found from /api/v1/roadmap/epic-status, which reads as "no such epic" rather than "wrong case". Either EqualFold both comparisons or make both exact; the asymmetry is the part that surprises.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix (fd9bbb1).

Comment thread internal/api/handlers_roadmap.go Outdated
pub, err := h.opts.Roadmap.Current()
if err != nil {
slog.Warn("roadmap publication unavailable", "error", err)
writeErrorCode(w, http.StatusServiceUnavailable, roadmapCodeUnavailable, err.Error(), nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the 503 body returns the raw err.Error(), which carries server-side filesystem paths.

roadmap.Reader.Current() wraps I/O errors verbatim, so this writes things like roadmap publication unavailable: roadmap-state checkout: open /tmp/roadmap-state/publication.json: no such file or directory, or git stderr from rev-parse, straight into the response and into the MCP tool result the model sees.

Failure scenario: any authenticated caller hitting the endpoint before the first successful clone learns ROADMAP_STATE_LOCAL_PATH and the state of the pod filesystem. Low severity — the group is authenticated and gitops.Reader redacts tokens from git output — but the rest of the package tends to log the detail (slog.Warn above already does) and return a fixed sentence. A constant message such as "no verified roadmap publication" would keep the "cannot say" semantics intact while dropping the internals.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix (fd9bbb1).

Comment thread internal/mcp/roadmap.go Outdated
if epic == "" {
return mcplib.NewToolResultError("missing required argument: epic"), nil
}
body, err := s.apiGet(ctx, "/api/v1/roadmap/epic-status?"+url.Values{"epic": {epic}}.Encode())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — apiGet collapses 404 and 503 into the same tool error, discarding the status the description promises to distinguish.

doRequest turns every status >= 400 into fmt.Errorf("%s", apiErr.Error), keeping only the error string and dropping both the status code and the code field (roadmap_unavailable vs epic_not_found). The tool then reports both as Failed to read epic status: .... The description added just above states that an unavailable publication means "cannot say", never "nothing is ready" — a promise that now rests entirely on the model reading the prose in the message.

This repo already has the counterpart: apiGetStatus exists precisely because "the lifecycle read has to tell 503 from 404, and those two are one sentence apart in the body and a whole decision apart in meaning" (server.go:2547). The same argument applies verbatim here. Using apiGetStatus and branching on the status would make the distinction structural rather than textual.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9bbb1: the tools use apiGetStatus; 503 → 'readiness is UNKNOWN, not empty', 404 epic_not_found → 'Epic not found', anything else → HTTP status + message.

Comment thread internal/mcp/roadmap.go Outdated
mcplib.WithString("epic",
mcplib.Description(`Epic name or root issue ("mctlhq/.github#42"). Omit for every active epic.`),
),
mcplib.WithString("required_only",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — required_only is declared as a string, so a client sending a JSON boolean silently gets the default.

stringArg does req.GetArguments()[name].(string) and returns "" on a type mismatch. A model that reads required_only as a flag and sends "required_only": false (a JSON boolean, which the schema type does not forbid at every client) fails the assertion, the parameter is dropped from the query string, and the handler applies requiredOnly = true — the caller asked for optional items and gets only the required ones, with no error anywhere.

mcplib.WithBoolean would make the schema say what is meant; if the string form is deliberate (to keep stringArg/extractStringParams uniform), a bool fallback in the handler would at least not silently invert the request.

}
}

func TestRoadmap_EpicStatusAndReady(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — no 200 test for GET /api/v1/roadmap/epics.

TestRoadmap_UnconfiguredOrBrokenAnswers503 covers the 503 path for all three routes, and this test covers the 200 shape for /epic-status and /ready, but nothing asserts the success shape of /epics. ListRoadmapEpics is the only handler whose response body (epics + provenance) is unverified, so a regression in its shaping — a renamed key, an empty list from a lifecycle filter added later — would pass the suite. roadmap_test.go covers Publication.Epics() itself, not the HTTP envelope.

A few lines in this test asserting 200, a non-empty epics array and a populated provenance would close it. (Also raised by the Antigravity pass.)

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P2, 6 P3.

P2: roadmap.Reader.Current() calls src.Revision() before the cache check, and gitops.Reader.Revision() shells out to git rev-parse HEAD — so every roadmap REST request and every MCP roadmap tool call forks a subprocess even on a cache hit. refresh() already computes HEAD under the write lock; caching it there removes the fork.

P3: empty manifests[].sha256 passes verification; name lookup is case-sensitive while root-issue lookup is not; the 503 body returns raw err.Error() with local paths; apiGet collapses 404 and 503 (the apiGetStatus precedent applies); required_only declared as a string silently defaults on a JSON boolean; no 200 test for /api/v1/roadmap/epics.

The verification logic, the tamper table, the revision-keyed cache and the byte-for-byte passthrough all hold up; ReadFiles correctly takes files and HEAD under one read lock, the routes sit inside auth and outside the write group, and the kill switch fails toward off. Could not run go build / go test in this environment, so the review is static.

One kill-switch reader for WORK_ITEMS_DISABLED and ROADMAP_STATE_DISABLED;
docs list f as false, matching the code.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
…rrors

Review round on #356:
- gitops.Reader records HEAD at the end of every refresh; Revision and
  ReadFiles read it instead of forking git per request.
- The roadmap reader caches a failed verification per revision too, so a
  broken publication is re-read only when the checkout moves.
- mctl_get_ready_work_items declares required_only as a boolean (a string
  is still accepted; anything else is refused, never dropped).
- MCP tools use the status: 503 says readiness is UNKNOWN, 404 says epic
  not found.
- A fixed 503 message; the detail stays in the log.
- Refuse a manifest without a digest; fold case on epic names like issues;
  ReadFiles refuses symlinks.
- Tests: MCP handler tests (query building, encoding, 503 vs 404), 200 on
  /epics, negative cache, symlink, revision after refresh.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 43s —— View job


Code review complete

  • Read the pinned diff (4786233...fd9bbb1)
  • Review internal/roadmap verification and the revision-keyed cache
  • Review gitops.Reader Revision/ReadFiles and the new head recording
  • Review REST handlers + router (auth placement, rate-limit group)
  • Review MCP tools + cmd/api wiring, allowlist and OpenAPI
  • Post inline findings and submit verdict

Verdict: changes requested — 0 P1, 1 P2, 2 P3. Findings are posted inline on fd9bbb1.

What the fix commit closed

Every finding from the previous round is addressed, and each one is pinned by a test rather than by prose:

Previous finding Now
required_only declared as a string mcplib.WithBoolean, handler switches on bool / string / refuses anything else — TestGetReadyWorkItemsToolBuildsTheQuery covers all four arms including required_only: 1
404 and 503 collapsed into one tool error roadmapFetch uses apiGetStatus; 503 says readiness is UNKNOWN, 404 says epic not found — TestRoadmapToolsKeepUnavailableApartFromNotFound
No MCP handler tests internal/mcp/roadmap_test.go, including # encoding in mctlhq/.github#57
503 body returned err.Error() constant message; detail to slog.Warn; the 503 test asserts no / leaks into the body
Empty manifests[].sha256 accepted refused in Parse, with a tamper case that re-signs so the check is actually reached
Name lookup case-sensitive, issue lookup not both fold; asserted in TestAcceptanceExamplesFollowThePublication
ReadFiles followed symlinks os.Lstat + IsRegular, with a symlink-out-of-checkout test
git rev-parse fork per request head recorded in refresh() under the write lock; Revision()/ReadFiles read the field
No negative cache cachedErr keyed by revision; TestReaderReverifiesOnlyWhenTheCheckoutMovesAndNeverServesStale counts the reads
No 200 test for /roadmap/epics added

The defer that records head is placed correctly: registered after defer r.mu.Unlock() and before the credential-scrub defer, so LIFO runs it under the write lock and after the scrub, and gitOutput takes no lock of its own — no deadlock, no race with refresh().

P2 (blocking)

internal/api/handlers_roadmap_test.go:47 — the roadmap routes are never walked. roadmapRouter builds its own chi.NewRouter(), so NewRouter is never consulted by any roadmap test: removing the three r.Get("/roadmap/...") lines leaves go test ./... green with the endpoints returning 404, which the MCP tools would surface as "HTTP 404" — indistinguishable from "no such epic". This repo wrote that guard twice already (TestLifecycleRoutesAreRegistered, TestUsageRoutesAreRegistered, both with the rationale in a comment above them). An eight-line chi.Walk over NewRouter(Options{}) closes it, and is also the only thing that would catch the routes drifting out of the auth group or into the 20/min write budget.

P3 (non-blocking)

  • internal/roadmap/roadmap.go:288 — the duplicate-epic guard still compares names exactly while find now folds case, so lifecycle-ownership and Lifecycle-Ownership both verify and then resolve to whichever comes first in manifest order.
  • internal/gitops/reader.go:242 — git -C <path> rev-parse HEAD walks up to an enclosing repository, so a ROADMAP_STATE_LOCAL_PATH inside another checkout (or a failed clone) can record a plausible head that belongs to a different repo and serve it as state_revision. rev-parse --git-dir pins it.

Checked and not flagged

  • Routes sit inside the authenticated group and outside the write group; the OpenAPI additions inherit the global security: BearerAuth, and the new schemas/paths are well-formed.
  • Publication is immutable after Parse; Provenance, Epics and ReadyWorkItems all return fresh values, so the shared cached publication is safe across goroutines. Lock order is roadmap mu → gitops RLock, one direction only.
  • killSwitchOn is a pure rename with its test carried over, and workItemsDisabled has no leftover callers.
  • Portal allowlist entries are enabled: false with reasons; recordedHints and the read-only name list are both updated, so the tool-count and allowlist drift guards agree.

Caveat: go build / go vet / go test were not permitted in this environment, so this is a static review — CI should be green before merge.
· branch feat/333-roadmap-read-api

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit fd9bbb1.

No significant issues found.

return out, "0123abc", nil
}

func roadmapRouter(h *Handlers) chi.Router {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — nothing pins that the three roadmap routes are registered, and this file is the only roadmap test that touches HTTP.

roadmapRouter builds a fresh chi.NewRouter() and mounts the handlers directly, so the whole suite passes without NewRouter ever being consulted. Delete the three r.Get("/roadmap/...") lines from internal/api/router.go and go test ./... stays green while GET /api/v1/roadmap/epics answers 404 — and the MCP tools, which go through REST, report Failed to read epic status (HTTP 404): 404 page not found, which a model will read as "no such epic" rather than "the endpoint is gone".

This repo has already been bitten by exactly this and wrote the guard twice, with the reasoning spelled out above each: TestLifecycleRoutesAreRegistered ("deleting a route line left the suite green while the endpoint did not exist ... that is how recover came to be listed in the PR description and absent from the router") and TestUsageRoutesAreRegistered ("a handler with no route is an endpoint that does not exist"). The same chi.Walk over NewRouter(Options{}), asserting the three GET /api/v1/roadmap/* paths, is about eight lines here. It is also the only thing that would notice if the routes later moved out of the authenticated group or into the 20/min write group — neither of which this file can see, since it never builds the real router.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 639bdcc: TestRoadmapRoutesAreRegistered walks NewRouter; deleting a roadmap route line fails it.

Comment thread internal/roadmap/roadmap.go Outdated
if m.Epic == nil || m.Epic.Name == "" || m.Epic.Lifecycle == "" {
return nil, unavailable("manifest %s carries no epic identity", m.Path)
}
if names[m.Epic.Name] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the duplicate-epic guard is case-sensitive, but find is now case-insensitive, so two epics that differ only in case verify cleanly and then resolve ambiguously.

names[m.Epic.Name] matches exactly, while find (roadmap.go:410) folds both the name and the root issue with strings.EqualFold — the change this commit made in response to the previous round. A publication carrying lifecycle-ownership and Lifecycle-Ownership as two manifests therefore passes verification, and EpicStatus("Lifecycle-Ownership") silently answers with whichever of the two comes first in manifest order: the readiness and health of the wrong epic, served 200, with provenance naming the wrong manifest path and digest.

Low likelihood — the names come from a generator, and the rest of this function refuses far less exotic inconsistencies. But it is the one invariant in Parse whose comparison no longer agrees with the lookup it protects; folding the key here keeps "two epics are the same epic" as one definition instead of two.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 639bdcc: the guard folds case; tamper case 'two epics equal but for case' added (fails with the exact-match guard).

Comment thread internal/gitops/reader.go
// may not, and an absent checkout has none.
defer func() {
r.head = ""
if out, err := r.gitOutput(nil, "rev-parse", "HEAD"); err == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — git rev-parse HEAD walks up to a parent repository, so head can name a commit that has nothing to do with this checkout.

Caching HEAD here is the right fix for the per-request fork, and the defer's placement is correct: registered after defer r.mu.Unlock() and before the credential-scrub defer, so LIFO runs it under the write lock and after the scrub. The gap is the command itself — git -C <localPath> rev-parse HEAD succeeds when localPath is not a repository but sits inside one, and answers with the enclosing repository's HEAD.

Failure scenario: ROADMAP_STATE_LOCAL_PATH points at a path under an existing checkout (a dev box, or a job whose workspace is the repo), and the clone fails or has not happened yet. refresh() returns an error, which RefreshLoop only logs, but this defer still records a plausible 40-hex head. Revision() then reports it, ReadFiles reads whatever publication.json is at that path, and if that file verifies internally it is served with state_revision naming an unrelated commit — the one field clients are told to trust for "which roadmap-state commit am I looking at". Checking rev-parse --git-dir (or comparing --show-toplevel with localPath) keeps the field meaning what it says.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 639bdcc: HEAD is recorded only when localPath has its own .git; test clones a missing repo into a dir inside another repo and expects no revision (fails without the check).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 0 P1, 1 P2, 2 P3.

Every finding from the previous round is fixed and pinned by a test: required_only is now WithBoolean with a bool/string/refuse switch (TestGetReadyWorkItemsToolBuildsTheQuery), roadmapFetch uses apiGetStatus and keeps 503 apart from 404, the 503 body is a constant while the detail goes to slog, an empty manifests[].sha256 is refused, name lookup folds like the root-issue lookup, ReadFiles rejects non-regular files (symlink test included), head is recorded under the write lock in refresh() so the cache-hit path no longer forks git, and Parse failures are cached per revision.

P2: the roadmap HTTP tests build their own chi router, so nothing exercises NewRouter — deleting the three r.Get("/roadmap/...") lines leaves the suite green with the endpoints gone. This repo wrote that exact guard twice already (TestLifecycleRoutesAreRegistered, TestUsageRoutesAreRegistered); an eight-line chi.Walk closes it and is also the only thing that would notice the routes drifting out of the auth group or into the write budget.

P3: the duplicate-epic guard in Parse is still case-sensitive while find now folds case, so two epics differing only in case verify and then resolve ambiguously; git -C <path> rev-parse HEAD in the refresh defer walks up to an enclosing repository, so a misconfigured or unclonable ROADMAP_STATE_LOCAL_PATH can record a head that belongs to another repo and be served as state_revision.

The verification core, the tamper table, the byte-for-byte fixture comparisons, the revision-keyed cache and the one-checkout locking in ReadFiles all hold up, and the routes sit inside AuthMiddleware and outside the 20/min write group. go build and go test were not permitted in this environment, so this is a static review; CI should be checked before merge.

…EAD only

- TestRoadmapRoutesAreRegistered walks NewRouter for the three reads.
- The duplicate-epic guard folds case, as lookup does.
- refresh records HEAD only when localPath has its own .git, so a failed
  clone inside another repository never borrows that repository's HEAD.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 1s —— View job


Code review complete

  • Read the pinned diff (4786233...639bdcc)
  • Review internal/roadmap verification and the revision-keyed cache
  • Review gitops.Reader Revision/ReadFiles and the recorded head
  • Review REST handlers + router registration guard
  • Review MCP tools + cmd/api wiring, allowlist, OpenAPI, chart
  • Post inline findings and submit verdict

Verdict: approved — 0 P1, 0 P2, 3 P3. Findings are posted inline on 639bdcc.

What the fix commit closed

Previous P2/P3 Now
Roadmap routes never walked; deleting them left the suite green TestRoadmapRoutesAreRegistered walks NewRouter(Options{}) with chi.Walk and asserts all three GET /api/v1/roadmap/*
Duplicate-epic guard compared names exactly while find folds names is keyed on the folded name, with a tamper case that re-signs so the check is actually reached
git -C <path> rev-parse HEAD could walk up to an enclosing repository the refresh defer gates on os.Stat(localPath/.git) first

The nested-repo test is not vacuous: work is a real clone, so work/nested genuinely sits inside a repository and a failed clone there would have reported the parent's HEAD without the guard.

What holds up

The verification chain is the strong part and it is unchanged: apiVersion/kind pinning, the sha256 check of snapshot.json/ready-set.json/health.json against publication.json (with an empty want refused), the one-ready-set-and-one-health-per-manifest bijection plus the leftover-epic check, and the ready list cross-checked against each item's own state drawn from a closed vocabulary. The fixture being a real publication copied byte for byte means the tests never restate a readiness of their own.

Concurrency is sound. head is written only inside refresh() under the write lock and read under RLock by Revision()/ReadFiles, so the cache-hit path no longer forks git and the "files and revision are one checkout" claim holds. The head-recording defer is registered after defer r.mu.Unlock() and before the credential-scrub defer, so LIFO runs it under the write lock and after the scrub, and gitOutput → redactTokenLocked requires exactly that lock. Lock order is roadmap mu → gitops RLock, one direction only; Publication is immutable after Parse and every accessor returns fresh values.

ReadFiles refuses anything that is not a plain name and Lstat-gates on IsRegular, so a committed symlink is not followed out of the checkout — and the check cannot be raced, since the only writer (refresh) needs the write lock. Routes sit inside AuthMiddleware and outside the 20/min write group. killSwitchOn fails toward off. ghtoken.Static("") yields the no-credential branch for the public repo. recordedHints and the read-only name list are both updated, so the tool-count and allowlist drift guards agree, and both portal entries are enabled: false with reasons. The OpenAPI additions are well-formed and inherit the global BearerAuth.

P3 (non-blocking)

Finding Location
The duplicate guard now folds the epic name, but nothing guards the root issue — the other lookup key find resolves on. Two epics sharing an issue verify cleanly and then resolve to whichever comes first in manifest order, serving the wrong epic's readiness with provenance naming the wrong manifest internal/roadmap/roadmap.go:291
ROADMAP_STATE_LOCAL_PATH defaults to /tmp/roadmap-state on the container root filesystem, while GITOPS_LOCAL_PATH gets a dedicated emptyDir with sizeLimit: 500Mi. Bounded in practice by --depth=1, but the storage story is invisible in the chart cmd/api/main.go:257
The five new ROADMAP_STATE_* vars are documented in README but absent from .env.example, which this commit otherwise edits .env.example:23

Fix the P3 findings →

Caveat: go build, go vet and go test were not permitted in this environment, so this is a static review. CI should be green before merge.
· branch feat/333-roadmap-read-api

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 639bdcc.

No significant issues found.

// Folded, because lookup folds: two names equal but for case would
// otherwise both verify and then resolve ambiguously.
folded := strings.ToLower(m.Epic.Name)
if names[folded] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the duplicate guard now folds the epic name, but nothing guards the other lookup key: the root issue.

find (roadmap.go:407) resolves on either e.epic.Name or e.epic.Issue.String(), and the root-issue form is the one the MCP tool description advertises ("mctlhq/.github#57") and the one TestAcceptanceExamplesFollowThePublication exercises. entry.epic.Issue comes straight from r.wire.Epic.Issue (roadmap.go:313) with no uniqueness check anywhere in Parse.

Failure scenario: two EpicDefinitions in mctlhq/.github carry the same issue block (a copy-paste in a generated manifest is the realistic way this happens). Both manifests verify — distinct names, distinct paths, matching digests — and GET /api/v1/roadmap/epic-status?epic=mctlhq/.github%2357 answers 200 with whichever comes first in manifest order: the wrong epic's readiness and health, with provenance naming the wrong manifest path and sha256. The same ErrEpicNotFound-vs-ambiguity distinction this commit just closed for names is still open for issues, and the name case is the one that could only ever have come from a generator bug too.

Folding the issue into the same names set (or a sibling set keyed on Issue.String()) closes it with one line and the tamper table already has the shape for the test.

Comment thread cmd/api/main.go
roadmapGit = gitops.NewReader(
envOr("ROADMAP_STATE_REPO_URL", "https://github.com/mctlhq/.github.git"),
envOr("ROADMAP_STATE_BRANCH", "roadmap-state"),
envOr("ROADMAP_STATE_LOCAL_PATH", "/tmp/roadmap-state"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the second checkout defaults onto the container's root filesystem, where the first one deliberately does not.

GITOPS_LOCAL_PATH is /data/mctl-gitops in helm/values.yaml, and helm/templates/deployment.yaml:120 backs it with a dedicated emptyDir carrying sizeLimit: 500Mi. /tmp/roadmap-state has neither: it lands on the node's ephemeral storage, which the chart sets no request or limit for, so the only thing bounding it is the node's disk-pressure eviction.

The blast radius is small in practice — refresh() clones --depth=1 --single-branch (reader.go:337) and re-fetches at the same depth, so the checkout stays around a megabyte and does not grow with the branch's history. What is left is the asymmetry: an operator reading the chart sees one checkout with a sized volume and no sign that a second one exists, and repeated fetch --depth=1 on a long-lived pod does accumulate loose objects with no git gc in the loop.

A ROADMAP_STATE_LOCAL_PATH entry under values.yaml's env: map (which already passes through generically, so no template change is needed) pointing at the existing volume, or a second small emptyDir, would make the storage story explicit.

Comment thread .env.example
AUDIT_DB_URL=
# workitem/v1 store (/api/v1/work-items*); falls back to AUDIT_DB_URL.
# Any WORK_ITEMS_DISABLED value except false/0/no/off turns it off (routes answer 503).
# Any WORK_ITEMS_DISABLED value except false/f/0/no/off turns it off (routes answer 503).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — this commit touches .env.example to correct the WORK_ITEMS_DISABLED wording, but does not add the five ROADMAP_STATE_* variables it introduces.

README.md gains all five rows (ROADMAP_STATE_REPO_URL, _BRANCH, _LOCAL_PATH, _TOKEN, _DISABLED), and .env.example is the file an operator copies to .env — WORK_ITEMS_DB_URL / WORK_ITEMS_DISABLED are both listed there for exactly that reason. Someone bringing up a local API from the template has no indication that a second git checkout is about to be cloned into /tmp/roadmap-state, nor the one variable that stops it. That matters more for this feature than for most, because the reader starts unconditionally at boot and fails only into log lines.

Five commented lines mirroring the WORK_ITEMS_* block would keep the template and the README saying the same thing.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (3 P3). Good to merge.

Every finding from the previous round is closed, each pinned by a test rather than by prose: TestRoadmapRoutesAreRegistered walks the real NewRouter for the three GETs, the duplicate-epic guard folds to match find, and the refresh defer now gates rev-parse HEAD on localPath/.git so a failed clone inside another repository cannot borrow its HEAD (the nested-repo test is genuine: work is a real checkout, so it would fail without the guard).

Verification core, locking and wiring all hold up: digest gating against publication.json, the manifest/ready-set/health bijection with the leftover check, the ready-list vs. item-state cross-check over a closed vocabulary, the revision-keyed cache that caches failures per revision and never serves stale, ReadFiles taking files and HEAD under one read lock with Lstat-gated regular files only, head written under the write lock and read under the read lock, routes inside AuthMiddleware and outside the 20/min write group, and killSwitchOn failing toward off. The OpenAPI additions are well-formed and inherit the global BearerAuth.

P3: no uniqueness guard on the root issue, which is a first-class lookup key alongside the now-folded name; ROADMAP_STATE_LOCAL_PATH defaults to /tmp while the sibling checkout gets a size-limited emptyDir; the five new ROADMAP_STATE_* vars are in README but not .env.example.

Caveat: go build/go vet/go test were not permitted in this environment, so this is a static review — CI should be green before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(roadmap): expose EpicDefinition status and ready work items through REST/MCP

2 participants