Skip to content

feat(work-items): workitem/v1 REST routes, wiring and OpenAPI (#349) - #354

Merged
mashkovd merged 13 commits into
mainfrom
feat/349-workitems-api
Sep 23, 2026
Merged

mashkovd merged 13 commits into
mainfrom
feat/349-workitems-api

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Closes #349. Stacked on #351 (the store); I'll retarget to main once #351 merges.

Steps 2 and 4 of docs/work-context-contract.md on top of the internal/workitems store.

Routes

Route Handler
POST /api/v1/work-items open; idempotent per tenant Idempotency-Key; dedupes open work on external_key
GET /api/v1/work-items list; default open; tenant, state, owner, limit
GET /api/v1/work-items/{id} {schema_version, work_item, state_version, latest_execution}. This is the route the mctl-agents client calls (orchestrator/work_context/client.py ROUTES["get_work_item"])
PATCH /api/v1/work-items/{id} complete / archive / supersede / wait; expected_state_version required
POST .../intents ≤ 8 KiB text + params object, secret-scanned
GET|POST .../executions list / attach or correlate a phase
POST .../resume next execution; waiting → active; refused while one runs or on terminal
POST .../surface-refs correlation for reply routing
GET .../events lifecycle history (ids only)

Mutations join the shared 20/min write group; reads stay outside it, as the contract specifies. Not in this PR, each with its own issue: snapshots (#352), approvals + the retention sweeper (#353), surface-identities (#350). The contract's "Implementation status" section now says so.

Acceptance criteria → evidence

AC Evidence
routes exist and match the route table router.go; TestSpecParses pins every routed method in the OpenAPI; routes not built are listed above with their issues
principal only from auth.UserFromContext; a caller actor is never authoritative principalOf (github:/service:/oidc: by how the caller authenticated). decodeWorkItemBody refuses actor, actor_principal, owner(_principal), created_by, on_behalf_of, subject, delegated_actor, user(_id) with 400 actor_not_accepted on every mutating route. TestWorkItems_ForgedActorIsRejectedEverywhere (incl. "actor":"tg:123") also asserts nothing changed
surface actor only through #350; until then direct principals only no delegation path exists. actor_external_id is stored as correlation and never becomes an actor: TestWorkItems_SurfaceActorIsCorrelationNeverIdentity checks that the service principal acts as itself, the subject stays github:alice, and history carries no tg:/chat ids
open (incl. replay), read, resume, concurrent transitions → 409 TestWorkItems_OpenReadAndPersist, TestWorkItems_IdempotentOpen, TestWorkItems_ResumeStartsTheNextExecution, TestWorkItems_StaleVersionIs409WithCurrentState; store-level race test in #351
stale/missing fails explicitly 404 work_item_not_found; 409 state_version_conflict / invalid_transition / execution_active with details.state/state_version
no transcripts TestWorkItems_NoTranscriptInHistoryOrAudit checks the 8 KiB cap, secret rejection, and that neither events nor audit entries carry intent text
Postgres tests with TEST_DATABASE_URL all handler tests use a real store, scoped to a per-test tenant (no unscoped deletes); TestWorkItems_UnconfiguredStoreAnswers503 runs without a DB

Authorization: admin; otherwise HasTenantAccess plus visibility (private → owner only), re-evaluated per request. An invisible item is 404 (TestWorkItems_VisibilityAnswers404AndTenantAccess403). An external_key that resolves to someone else's private item returns 409 external_key_in_use without disclosing it. superseded_by must be visible to the caller.

Wiring: WORK_ITEMS_DB_URL → AUDIT_DB_URL fallback; WORK_ITEMS_DISABLED kill switch; a nil store makes every route answer 503 with a startup warning.

Unrelated fix: internal/openapi/openapi.yaml on main does not parse. An unquoted : in a human-input description (line 2341) broke /openapi.yaml for every client generator. It is quoted here, and internal/openapi/embed_test.go now parses the spec: that test fails on main's file and passes here.

Mutation proof (each mutant is killed)

  • skip the forbidden-field scan → ForgedActorIsRejectedEverywhere
  • canSeeWorkItem: drop the tenant check / drop the private-owner check → VisibilityAnswers404AndTenantAccess403
  • drop the tenant check on create / the viewer filter on list → same test
  • principalOf: drop the github: / service: branch → OpenReadAndPersist / SurfaceActorIsCorrelationNeverIdentity
  • accept a header/body Idempotency-Key mismatch → IdempotentOpen

go test -p 1 ./... against postgres:17: all pass. golangci-lint: only the existing gosec G710 on /docs (router.go:235), which this PR does not touch.

Consumer alignment

The mctl-agents mirror (orchestrator/work_context/contract.py) expects pre-contract spellings (work_item_id, open/in-progress/..., executions[].sequence). It fails closed to WORK_ITEM_UNKNOWN behind WORK_CONTEXT_ROLLOUT_MODE=off, and it says it will adopt #227's names once they are readable. That alignment is a separate mctl-agents change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G

Steps 2 and 4 of docs/work-context-contract.md over the internal/workitems
store: open, list, read, transition, intents, executions, resume,
surface-refs and events under /api/v1/work-items; mutations share the 20/min
write budget.

- The acting principal comes only from authentication, rendered as
  github:<login>, service:<id> or oidc:<id>. A body naming an actor
  (actor, owner_principal, created_by, on_behalf_of, ...) is refused with
  400 actor_not_accepted; actor_external_id on a surface reference is
  correlation only. Delegation waits for mctl-api#350.
- Authorization is re-evaluated per request: admin, or tenant access plus
  visibility; an item the caller may not see is 404.
- Typed error codes for 404/409/400; 409s carry the current state and
  state_version.
- WORK_ITEMS_DB_URL (falls back to AUDIT_DB_URL) and WORK_ITEMS_DISABLED;
  nil store answers 503.
- OpenAPI paths and schemas, README, .env.example, and the contract's
  implementation status. Also quotes a description in openapi.yaml that made
  the served spec unparseable, with a test that parses it.

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

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 8m 30s —— View job


Review posted — changes requested (0 P1, 2 P2, 3 P3)

  • Gather context (diff vs bbb6cc3, store API, auth helpers, router, CI config)
  • Review internal/api/handlers_work_items.go
  • Review router wiring, cmd/api/main.go, OpenAPI
  • Review test coverage
  • Post inline findings + verdict

Five inline comments are on the diff; the review verdict is submitted against f62f3a4.

What holds up

The authorization model matches docs/work-context-contract.md §"Identity and authorization": principal only from auth.UserFromContext, canSeeWorkItem re-evaluated per request, invisible items answered 404 rather than 403, superseded_by gated on the caller's own visibility, and ListFilter failing closed when Tenants is empty. The forged-actor scan in decodeWorkItemBody runs before decoding on every mutating route, and the tests do kill the mutants the description claims. CI supplies TEST_DATABASE_URL (validate.yml:53), so the Postgres-backed tests actually run rather than silently skipping. Reads sit under the global 300/min limiter, mutations under the 20/min write group, as the contract specifies. go build ./... is clean.

P2 — should fix before merge

1. X-Request-Id breaks every mutating route (handlers_work_items.go:196)
chi's middleware.RequestID (router.go:203) passes the inbound X-Request-Id header through verbatim and unbounded. mutationFor copies it into Mutation.RequestID, and Mutation.validate() caps request_id at MaxExternalIDBytes (256) and requires valid UTF-8 — so a long or non-UTF-8 header turns create/transition/intent/execution/resume/surface-ref into 400 invalid_request blaming a field the caller never sent. truncateUA/maxUserAgentLen in clientmeta.go is the existing precedent for exactly this input class.

2. Idempotency hash is not bound to the principal (handlers_work_items.go:206)
The hash preimage is op + item/tenant + body, and Actor is deliberately never in the body — so it never reaches the hash. Both store key namespaces ((tenant, key) and (work_item_id, key)) are cross-principal. A second principal replaying another's key gets 200 OK and the item, with the write recorded against someone else — the inverse of the rule this file opens with ("a caller that believes it acted as someone else must find out it did not"). One-line fix by folding m.Actor into the preimage; a genuine retry from another pod of the same service still hashes identically.

P3

  • limit > 500 is silently clamped by Store.List while the new OpenAPI declares maximum: 500; len(items) < limit paging then drops work. No test covers limit at all. (:345)
  • Empty tenant answers 403 tenant_forbidden with no access to tenant "" instead of 400 invalid_request, against a spec that marks it required. (:283)
  • AttachWorkItemExecution audits unconditionally, so a pure idempotent replay writes duplicate audit rows; the other five handlers gate on created. (:512)

The unrelated openapi.yaml quoting fix plus internal/openapi/embed_test.go is a good catch and worth landing regardless of the above.
· branch feat/349-workitems-api

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

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

Findings

Finding 1

  • Severity: P3
  • File: internal/api/handlers_work_items_test.go:68
  • Anchor: + r.Get("/api/v1/work-items/{id}/executions", h.ListWorkItemExecutions)
  • Concrete failure scenario: The route GET /api/v1/work-items/{id}/executions (ListWorkItemExecutions) is registered in both router.go and workItemsRouter, but no test in handlers_work_items_test.go invokes this endpoint. If a regression occurs in the execution list serialization, response ordering, or visibility enforcement on this route, the test suite will not detect it.
  • Proposed fix: Add a test case in handlers_work_items_test.go that attaches executions to a work item and asserts that GET /api/v1/work-items/{id}/executions returns HTTP 200 with the expected schema version, execution details, and attempt ordering.

Finding 2

  • Severity: P3
  • File: internal/api/handlers_work_items.go:470
  • Anchor: + writeJSON(w, http.StatusOK, map[string]any{"schema_version": workitems.SchemaVersion, "executions": execs})
  • Concrete failure scenario: When a work item has no attached executions, h.opts.WorkItems.Executions may return a nil slice. Serializing a nil Go slice to JSON produces {"executions": null} instead of {"executions": []}. According to openapi.yaml, executions is defined as type: array without nullable: true. Strictly-typed API clients or schema validators expecting an array may fail when receiving null. The same situation can occur in ListWorkItems (items) and ListWorkItemEvents (events).
  • Proposed fix: Ensure nil slices are coerced to empty slices before responding:
    if execs == nil {
        execs = []workitems.Execution{}
    }

Finding 3

  • Severity: P3
  • File: internal/api/handlers_work_items_test.go:260
  • Anchor: +func TestWorkItems_VisibilityAnswers404AndTenantAccess403(t *testing.T) {
  • Concrete failure scenario: TransitionWorkItem contains authorization validation ensuring body.SupersededBy targets a work item the caller has permission to view (answering 400 invalid_request so existence is not leaked). Similarly, CreateWorkItem contains logic returning 409 external_key_in_use if external_key deduplication resolves to a private item owned by another principal. Neither of these authorization/oracle boundary checks is exercised in handlers_work_items_test.go.
  • Proposed fix: Add test cases in handlers_work_items_test.go verifying that:
    1. Patching an item with superseded_by pointing to a nonexistent ID or another user's private item returns HTTP 400 invalid_request.
    2. Attempting to create an item with an external_key that collides with an unviewable private item returns HTTP 409 external_key_in_use.

…349)

Follows the store change on #351: a create whose external_key names work the
caller could not open answers 409 without disclosing the item. Test covers
both a private and a tenant request against another member's private item.

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 7m 12s —— View job


Review complete

  • Gather context (diff, store API, router, auth helpers)
  • Review internal/api/handlers_work_items.go
  • Review router wiring, main.go, OpenAPI
  • Review test coverage
  • Post findings + verdict

Reviewed 443d494...df14525. Verdict submitted: changes requested — 0 P1, 2 P2, 5 P3, all posted as inline comments.

What holds up

The identity model is the strong part of this change. principalOf derives the actor purely from how the caller authenticated (IsService / GitHubLogin / fallback oidc:), never from the body; decodeWorkItemBody refuses the ten actor-naming keys with a typed 400 before anything else runs; canSeeWorkItem is re-evaluated per request and an invisible item answers 404, not 403. Input validation, the 8 KiB intent cap and secret scanning all live in the store, so the handlers stay thin. Every store list path initialises []T{}, so no route can emit null for a documented array (the agy P3 on that does not apply). Route placement matches the contract: mutations inside the 20/min write group, reads outside it. The OpenAPI addition covers every routed method, and TestSpecParses plus the quoting fix at old line 2341 are a real catch — that file did not parse before.

P2

# Location Finding
1 internal/api/handlers_work_items.go:143 writeWorkItemError's default arm drops the underlying error with no slog. Pool exhaustion, a context deadline, a scan error or any unmapped pgx error becomes an opaque 500 with nothing logged, on all ten routes — against the convention in handlers_lifecycle.go:199, handlers_usage.go:245, handlers_human_input.go:336. Fix this →
2 internal/api/handlers_work_items_test.go:292 The tenancy default in ListWorkItems (handlers_work_items.go:349-360) is never exercised — every list call in the suite passes ?tenant=. Swapping the default arm to f.AllTenants = true leaks every tenant's open work to any authenticated caller and the whole suite still passes. It is the one authorization branch in this PR without a named test. Fix this →

P3

  • handlers_work_items.go:208 — the request digest omits the acting principal (two callers in a tenant sending identical creates under the same key silently share one item, owned by the first) and varies with whether the key arrived in the header or the body, so a genuine retry that switches form is refused idempotency_key_reused.
  • handlers_work_items.go:285 — a missing tenant answers 403 tenant_forbidden; for an admin HasTenantAccess("") is true, so the same malformed body falls through to the store and yields 400. Same request, different status by caller.
  • handlers_work_items.go:310 — external_key_in_use is also returned for an invisible idempotency replay, including for callers that sent no external_key.
  • handlers_work_items.go:558 — ResumeWorkItem runs an Executions query whose result it discards, after the mutation is committed, turning a transient read failure into an error for an already-successful resume.
  • handlers_work_items_test.go:309 — GET .../executions, the superseded_by visibility gate (:396-404) and the limit validation are all routed but untested.

I could not run go build / go test in this environment (command approval), so the findings are from reading the diff plus the base-branch store, auth, and router code.
· branch feat/349-workitems-api

agy P3s on #354: the executions list (empty array, ordering) and
superseded_by pointing at a missing or invisible item.

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

Copy link
Copy Markdown
Member Author

agy round on f62f3a4 (all P3), addressed in aa50f0f:

  1. The executions list is now tested: an empty item returns [] and two executions come back in attempt order.
  2. The nil-slice concern does not apply: List, Executions and Events all build []T{}, never nil. The new test asserts "executions":[] literally.
  3. superseded_by naming a missing item or someone else's private item answers the same 400 invalid_request (tested). external_key onto another member's private item answers 409 external_key_in_use without an item id (added in df14525).

Copilot did not review: it reported the requester's quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

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

No significant issues found.

@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

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


Review posted

  • Gather context (diff, store API, router, auth helpers)
  • Review internal/api/handlers_work_items.go
  • Review router wiring, main.go, OpenAPI
  • Review test coverage
  • Post findings + verdict

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

The identity model is the strong part of this PR: principalOf keyed on how the caller authenticated rather than on a name, the forbidden-field scan running before any decode, 404-not-403 for invisible items, and actor_external_id kept strictly as correlation. The tests do kill the mutants the description claims they kill, and the latest commit already closes the executions-list and supersede-visibility gaps.

P2

1. mutationFor omits the acting principal from the create idempotency hash — internal/api/handlers_work_items.go:208

work_item_create_requests is keyed (tenant, idempotency_key) and the only tiebreaker is request_hash, which is built from op + tenant + json(createWorkItemBody). createWorkItemBody has no actor field — correctly, since identity never reaches the body — so the actor never enters the preimage. Two principals in one tenant sending the same key and body therefore collide: the second caller's create silently no-ops and returns the first caller's item, or, if that item is private, falls into the !canSeeWorkItem branch and answers 409 external_key_in_use on a request that carried no external_key. Keys like ci-run-1 or issue-42 are exactly what a shared automation surface generates. Adding principalOf(user) to the preimage keeps same-caller replay identical and turns the collision into the honest 409 idempotency_key_reused.

2. Unexpected store errors are swallowed — internal/api/handlers_work_items.go:143

The default: branch of writeWorkItemError returns 500 with no log line; the file does not import log/slog at all. A dropped connection or scan failure inside internal/workitems becomes an untraceable 500 on the route the mctl-agents client polls. Every other store-backed handler here logs it (handlers_human_input.go:336, handlers_agent_platform.go:180).

P3

  • AttachWorkItemExecution audits unconditionally (:514) — the only mutating route that does not gate on created, so idempotent replays write duplicate audit entries.
  • An omitted tenant on create answers 403 tenant_forbidden with no access to tenant "" instead of 400 (:285).
  • Idempotency-Key is read, hashed, then silently discarded on /surface-refs (:599); the body variant is rejected as an unknown field.
  • WORK_ITEMS_DISABLED=false disables work items (cmd/api/main.go:317), unlike AUTH_REQUIRED next door.
  • /surface-refs is missing from the forged-actor table (handlers_work_items_test.go:217) — the one route a reader is most likely to assume treats a caller-supplied actor as real.

Not reproduced

The earlier Antigravity finding about null instead of [] does not apply: List, Executions and Events all initialize out := []T{}, and TestWorkItems_ExecutionListAndSupersedeVisibility now pins "executions":[] explicitly.

I could not run go build / go test in this environment (command approval), so this is static review only.
· branch feat/349-workitems-api

Comment thread internal/api/handlers_work_items.go Outdated
}
m := workitems.Mutation{Actor: principalOf(user), Surface: surface, IdempotencyKey: key}
if meta, ok := ClientMetaFromContext(r.Context()); ok {
m.RequestID = meta.RequestID

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 — a client-supplied X-Request-Id header longer than 256 bytes makes every mutating work-item route return 400.

ClientMeta.RequestID comes from middleware.GetReqID, and chi's middleware.RequestID (internal/api/router.go:203) uses the inbound X-Request-Id header verbatim when present, only generating an id when the header is absent. It is unbounded and unvalidated. Here it is copied straight into Mutation.RequestID, and Mutation.validate() caps it:

// internal/workitems/inputs.go:127
if err := checkText("request_id", m.RequestID, MaxExternalIDBytes /* 256 */, false); err != nil {

so the store returns ErrInvalid, and writeWorkItemError maps that to 400 invalid_request with the message request_id exceeds 256 bytes — blaming a field the caller never put in the body. checkText also rejects non-UTF-8, which a raw header value can be.

All six mutating routes are affected, since CreateInput / TransitionInput / IntentInput / ExecutionInput / ResumeInput / SurfaceRefInput all call Mutation.validate() first. Reads are unaffected.

Repro: POST /api/v1/work-items with X-Request-Id set to 300 as and an otherwise valid body returns 400 instead of 201. An ingress or client library that injects a long opaque trace id breaks work-item writes for everything behind it.

This package already has the right precedent for caller-controlled metadata of exactly this shape — truncateUA / maxUserAgentLen in internal/api/clientmeta.go. Truncate (or drop) the request id here rather than letting it fail the write:

if meta, ok := ClientMetaFromContext(r.Context()); ok {
    m.RequestID = truncateRequestID(meta.RequestID) // cap at workitems.MaxExternalIDBytes
}

Worth a test that a 300-byte X-Request-Id still yields 201.

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 the latest push. truncateRequestID (following the truncateUA precedent) keeps the request id to 256 bytes of valid UTF-8 before it reaches the store. TestWorkItems_LongRequestIDNeverFailsAWrite sends a 300-byte id with an invalid byte and gets 201; without the truncation it gets 400.

writeError(w, http.StatusInternalServerError, "hash request")
return workitems.Mutation{}, false
}
sum := sha256.Sum256(append([]byte(op+"\x00"+itemID+"\x00"), canonical...))

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 — the idempotency request hash does not bind a key to the principal that used it, so one caller can replay another caller's key and be told their write succeeded.

The preimage is op + item/tenant + the decoded body. Mutation.Actor is deliberately kept out of the body (that is what forbiddenIdentityFields enforces), so it never reaches this hash. The key namespaces in the store are (tenant, idempotency_key) for create and (work_item_id, idempotency_key) for everything else — both cross-principal.

Consequence, with alice and bob both members of the tenant:

  1. alice: PATCH /work-items/{id} {"action":"archive","expected_state_version":1} with Idempotency-Key: k-1 → archived, event attributed to github:alice.
  2. bob: byte-identical request with Idempotency-Key: k-1 → replayed() matches on op+hash and returns 200 OK with the item. Nothing is recorded for bob, and bob is told the transition is his.

Same on create: bob gets 200 and alice's work item back; and if that item is visibility: private, the handler answers 409 external_key_in_use (line 308) even though no external_key was involved — a misleading code for what is really a key collision.

This is the accountability property this file's header states ("a caller that believes it acted as someone else must find out it did not"), and the store's own comment says a reused key is ErrIdempotencyKeyReuse, "never a silent replay". Folding the principal into the preimage restores that in one line — a genuine retry from another pod of the same service still hashes identically, while a different principal now gets the typed 409:

sum := sha256.Sum256(append([]byte(op+"\x00"+itemID+"\x00"+m.Actor+"\x00"), canonical...))

(Needs the m := workitems.Mutation{...} assignment moved above this block.) If the cross-principal sharing is intentional, it deserves a comment here and a test pinning it — as written it reads as an oversight.

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 at the store level, so the binding does not depend on the handler (see #351 964f2a8). Both key tables record the actor, and a different principal with the same key and body gets ErrIdempotencyKeyReuse. At the HTTP layer, TestWorkItems_AnotherPrincipalCannotReplayYourKey returns 409 idempotency_key_reused for bob; the misleading external_key_in_use path can no longer be reached that way.

writeErrorCode(w, http.StatusBadRequest, wiCodeInvalid, "limit must be a positive integer", nil)
return
}
f.Limit = n

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 — limit above the store maximum is silently clamped, contradicting the OpenAPI contract added in this PR.

The handler rejects non-numeric and <= 0, but passes anything larger straight through; Store.List then clamps to maxListLimit (500) without telling the caller. Meanwhile openapi.yaml declares limit: {minimum: 1, maximum: 500}, which a generated client reads as "values above 500 are rejected".

A caller asking for limit=1000 and receiving exactly 500 items has no way to distinguish "that is all of them" from "you were truncated" — the usual len(items) < limit ⇒ last page heuristic then silently drops work. Rejecting > workitems.MaxListLimit with 400 invalid_request (the same shape as the <= 0 case just above) matches the spec and makes truncation impossible to miss. maxListLimit would need exporting.

No test covers the limit parameter at all today — neither the <= 0 rejection nor the clamp.

Comment thread internal/api/handlers_work_items.go Outdated
if !decodeWorkItemBody(w, r, &body) {
return
}
if body.Tenant == "" || !user.HasTenantAccess(body.Tenant) {

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 — a missing/empty tenant answers 403 tenant_forbidden, not 400 invalid_request.

user.HasTenantAccess("") is false for a non-admin and true for an admin, so the body.Tenant == "" arm is what actually catches it — and it produces 403 with the message no access to tenant "". The OpenAPI added in this PR marks tenant as required, so a generated client expects 400 here; and "no access to tenant """ reads as an authorization decision about a tenant the caller never named.

Splitting the two cases keeps the typed codes meaningful:

if body.Tenant == "" {
    writeErrorCode(w, http.StatusBadRequest, wiCodeInvalid, "tenant is required", nil)
    return
}
if !user.HasTenantAccess(body.Tenant) { ... 403 ... }

writeWorkItemError(w, err)
return
}
h.auditWorkItem(r, user, "work_item.execution", item.ID, item.Tenant,

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 is the one handler that audits unconditionally; a pure idempotent replay writes a duplicate audit entry.

Every other mutating handler here gates on created (CreateWorkItem:311, AppendWorkItemIntent:451, ResumeWorkItem:553, LinkWorkItemSurface:605). AttachExecution returns created == false in two quite different situations: an existing execution whose phase was just advanced (a real change, worth auditing), and a replayed() hit on the same Idempotency-Key where nothing at all happened. As written, a client retrying an attach N times produces N work_item.execution audit rows for one event.

created alone is not the right gate either, since the phase-advance case should still be audited. Either have the handler compare the returned exec.Phase against the requested one, or let the store report "nothing changed" alongside created.

@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 authorization model, the forged-actor rejection and the 404-not-403 visibility handling all hold up under review, and the tests genuinely kill the mutants the description lists. Two things should be settled before merge:

  • P2 mutationFor copies the caller-controlled X-Request-Id header into Mutation.RequestID unbounded; the store caps request_id at 256 bytes, so a long header turns every one of the six mutating routes into 400 invalid_request. truncateUA in clientmeta.go is the existing precedent.
  • P2 The idempotency request hash omits Mutation.Actor, and both key namespaces are cross-principal, so a second principal replaying another's key gets 200 OK for a write recorded against someone else — the opposite of the accountability rule this file opens with.

P3s: limit > 500 silently clamped against a spec that declares maximum: 500; empty tenant answers 403 rather than 400; AttachWorkItemExecution audits on a pure replay where the other five handlers gate on created.

The unrelated openapi.yaml quoting fix plus embed_test.go is a good catch and worth landing regardless.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

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

Findings

Finding 1

  • Severity: P2
  • File and line: internal/api/handlers_work_items.go, ~line 584
  • Anchor: + v, err := h.viewOf(r, updated)
  • Concrete failure scenario: In ResumeWorkItem, h.opts.WorkItems.Resume executes and commits the transition and creates the new execution. Calling h.viewOf(r, updated) triggers a separate database query via h.opts.WorkItems.Executions(r.Context(), item.ID) to compute v.LatestExecution. However, the response payload on lines 589–592 builds its own JSON object referencing updated and exec directly and completely discards v.LatestExecution. If this redundant execution query fails due to a transient database error, connection issue, or context timeout, writeWorkItemError will return a 500 Internal Server Error to the caller, falsely reporting a failure even though the resume mutation succeeded and was audit-logged.
  • Proposed fix: Remove the h.viewOf call in ResumeWorkItem and build the response directly from updated and exec:
    writeJSON(w, createdStatus(created), map[string]any{
        "schema_version": workitems.SchemaVersion,
        "work_item":      updated,
        "state_version":  updated.StateVersion,
        "execution":      exec,
    })

Finding 2

  • Severity: P3
  • File and line: internal/openapi/openapi.yaml, ~line 2697
  • Anchor: + '201': {description: Resumed; the item and the new execution}
  • Concrete failure scenario: In internal/openapi/openapi.yaml, the 200/201 response definitions for /api/v1/work-items/{id}/resume (and similarly for /api/v1/work-items/{id}/intents, /api/v1/work-items/{id}/executions, and /api/v1/work-items/{id}/surface-refs) lack a content: application/json schema definition, unlike /api/v1/work-items and /api/v1/work-items/{id}. API clients and SDK generators parsing the OpenAPI document will generate untyped or void return types for these mutation endpoints.
  • Proposed fix: Add content: application/json schemas matching the JSON payloads returned by these handlers in internal/openapi/openapi.yaml.

Finding 3

  • Severity: P3
  • File and line: internal/api/handlers_work_items_test.go, ~line 173
  • Anchor: + list := e.do(alice, "GET", "/api/v1/work-items?tenant="+e.tenant, nil)
  • Concrete failure scenario: In internal/api/handlers_work_items_test.go, TestWorkItems_OpenReadAndPersist tests listing work items with an explicit ?tenant=... query param, but no test visible in this diff tests listing work items with the limit parameter, filtering by state or owner, or omitting the tenant parameter to exercise multi-tenant listing across user.Groups for non-admins or AllTenants for admins. Regressions in query parameter parsing or tenant resolution will not be caught.
  • Proposed fix: Add test cases in internal/api/handlers_work_items_test.go verifying limit validation (including invalid inputs), filtering by state/owner, and listing without the tenant query parameter.

writeError(w, http.StatusInternalServerError, "hash request")
return workitems.Mutation{}, false
}
sum := sha256.Sum256(append([]byte(op+"\x00"+itemID+"\x00"), canonical...))

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 — the create idempotency hash omits the acting principal, so two principals in the same tenant collide on one key.

work_item_create_requests is keyed (tenant, idempotency_key) (internal/workitems/store.go:253), and the only tiebreaker is request_hash. That hash is built here from op + tenant + json(createWorkItemBody) — and createWorkItemBody has no actor field, because identity deliberately never reaches the body. So the actor never enters the preimage.

Failure scenario: alice POST /api/v1/work-items with Idempotency-Key: ci-run-1 and {"tenant":"acme","title":"ship it"} → 201, item owned by github:alice. Bob, also in acme, sends the byte-identical body with the same key. The store finds the recorded row, hash == in.RequestHash, and returns alice's item. Bob gets 200 with owner_principal: github:alice and no item of his own was ever created — a silent no-op, not a typed error. If alice's item was visibility: private, CreateWorkItem falls into the !canSeeWorkItem branch at line 307 and answers 409 external_key_in_use / "external_key already names open work you cannot see" on a request that carried no external_key at all.

Keys like ci-run-1, issue-42 or a date are exactly the shapes a shared automation surface generates, so this is not only a UUID-collision hypothetical.

Handler-local fix: put the principal in the preimage, so a second caller reusing the key gets the honest 409 idempotency_key_reused instead of someone else's item:

sum := sha256.Sum256(append([]byte(op+"\x00"+itemID+"\x00"+principalOf(user)+"\x00"), canonical...))

A same-caller retry still hashes identically, so replay behaviour is unchanged. Worth a test that two users in one tenant sending the same key+body do not share an item.

Fix this →

case errors.Is(err, workitems.ErrInvalid):
writeErrorCode(w, http.StatusBadRequest, wiCodeInvalid, err.Error(), nil)
default:
writeError(w, http.StatusInternalServerError, "work-items store 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 (error handling): the default branch discards the store error entirely. Any failure this switch does not recognise -- pool exhaustion, a context deadline, a scan or column error, an unmapped pgx error -- becomes a bare 500 {"error":"work-items store error"} with nothing written to the log, on all ten work-item routes.

Elsewhere in this package an unexpected store error is logged before the response (handlers_lifecycle.go:199, handlers_usage.go:245, handlers_human_input.go:336), and CLAUDE.md asks for structured slog. As written a work-items outage is invisible in the API logs; the only signal is a 500 rate.

Suggested: slog.Error("work-items store error", "error", err) before the writeError call, so the discarded context reaches the operator.

list := e.do(bob, "GET", "/api/v1/work-items?tenant="+e.tenant, nil)
if items, _ := list.body["items"].([]any); len(items) != 1 {
t.Errorf("bob lists %s", list.raw)
}

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 (test coverage): every list call in this file passes ?tenant=, so the tenancy default in ListWorkItems (handlers_work_items.go:349-360) is never exercised -- neither case user.IsAdmin(): f.AllTenants = true nor default: f.Tenants = user.Groups.

That switch is the cross-tenant boundary for the list route. Swapping the default arm to f.AllTenants = true would hand every tenant open work to any authenticated caller, and the whole suite still passes. It is the one authorization branch in this PR without a named test, while canSeeWorkItem, the forbidden-field scan and the external_key oracle each have one.

Two assertions close it: GET /api/v1/work-items with no tenant query, as a member of a single tenant, returns only that tenant items (open one in a second tenant the caller is not in); and the same call as an admin sees both.

case errors.Is(err, workitems.ErrInvalid):
writeErrorCode(w, http.StatusBadRequest, wiCodeInvalid, err.Error(), nil)
default:
writeError(w, http.StatusInternalServerError, "work-items store 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 — the default branch swallows every unexpected store error: no slog line, no error text, nothing. handlers_work_items.go does not import log/slog at all, so a dropped connection, a pgx scan failure, a schema drift or a context deadline inside internal/workitems produces a bare {"error":"work-items store error"} and leaves zero trace on the server side.

Failure scenario: Postgres restarts mid-request. Store.Transition returns workitems: transition: %w wrapping a pgx connection error. It matches none of the sentinels above, so the caller sees 500 "work-items store error" and the operator has an untraceable 500 on the route the mctl-agents client polls — no way to tell a connection reset from a scan bug without adding logging and redeploying.

Every other store-backed handler in this package logs the unexpected case (handlers_agent_platform.go:180, handlers_human_input.go:336, handlers_human_input_response.go:202), which is also what CLAUDE.md's "structured logging with slog" asks for. Suggest:

default:
    slog.Error("work_items store error", "error", err)
    writeError(w, http.StatusInternalServerError, "work-items store error")

Passing the request through would let you add request_id/path too, but the bare line already closes the gap.

Fix this →

writeError(w, http.StatusInternalServerError, "hash request")
return workitems.Mutation{}, false
}
sum := sha256.Sum256(append([]byte(op+"\x00"+itemID+"\x00"), canonical...))

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 request digest is op + itemID + json(body), which leaves two seams.

  1. It does not include the acting principal. Two callers in the same tenant who send byte-identical creates under the same Idempotency-Key hash the same, so the second gets 200 with the first caller work item -- owner_principal and created_by naming someone else -- instead of opening its own. The store scopes the create key per tenant, not per actor, so the HTTP layer is the only place that can be separated. Folding principalOf(user) into the digest turns the collision into idempotency_key_reused rather than a silently shared item.

  2. It depends on where the key was carried. idempotency_key sits in each body struct with omitempty, so a first attempt with the Idempotency-Key header and a retry with the body field -- presented as equivalent in the OpenAPI text -- marshal differently, and the genuine retry is refused 409 idempotency_key_reused. TestWorkItems_IdempotentOpen covers the header/body mismatch case, not this one. Hashing the body with the key field cleared, or always set to the resolved key, makes the two spellings agree.

writeWorkItemError(w, err)
return
}
h.auditWorkItem(r, user, "work_item.execution", item.ID, item.Tenant,

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 is the only mutating route that audits unconditionally; CreateWorkItem (313), AppendWorkItemIntent (453), ResumeWorkItem (555) and LinkWorkItemSurface (607) all gate on created.

AttachExecution returns created == false both for a pure idempotency replay and for a correlate call whose phase already matches, so a client that retries POST .../executions with the same Idempotency-Key writes a fresh work_item.execution audit entry on every attempt even though the store did nothing. A phase change is a real event worth auditing, so the condition is not simply if created — if created || exec.Phase != previousPhase needs the store to report it, or gate on created and accept losing the phase-change entry.

Fix this →

Comment thread internal/api/handlers_work_items.go Outdated
if !decodeWorkItemBody(w, r, &body) {
return
}
if body.Tenant == "" || !user.HasTenantAccess(body.Tenant) {

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: a missing tenant is answered 403 tenant_forbidden with the message no access to tenant "". The OpenAPI marks tenant required on this body, and HasTenantAccess("") is false only incidentally -- for an admin it returns true, so an admin omitting tenant falls through to the store and gets 400 invalid_request instead, i.e. the same malformed request yields a different status depending on who sends it. Splitting the empty case out as 400 invalid_request makes it uniform and matches the documented shape.

Comment thread internal/api/handlers_work_items.go Outdated
if !decodeWorkItemBody(w, r, &body) {
return
}
if body.Tenant == "" || !user.HasTenantAccess(body.Tenant) {

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 omitted tenant is answered as 403 tenant_forbidden with no access to tenant "", not 400 invalid_request. The OpenAPI marks tenant required, so a client that forgets it gets a permission error for a validation mistake — confusing to debug and it makes 403 rates unusable as an authorization signal. Splitting the empty case out costs two lines:

if body.Tenant == "" {
    writeErrorCode(w, http.StatusBadRequest, wiCodeInvalid, "tenant is required", nil)
    return
}
if !user.HasTenantAccess(body.Tenant) { ... 403 ... }

claude[bot]
claude Bot previously approved these changes Sep 23, 2026

@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 (4 P3). Good to merge.

Both P2s from the previous round are closed, each pinned by a test that fails without the fix: writeWorkItemError now logs the unmapped store error before the 500, and TestWorkItems_ListWithoutTenantStaysInsideTheCallersTenants exercises both arms of the list tenancy switch (a member sees only its own tenant with no ?tenant=, an admin sees both). The earlier P3s also landed: limit is rejected above 500 rather than silently clamped (TestWorkItems_LimitAndTenantAreValidated), an empty tenant is 400 not 403, the request digest drops idempotency_key so header and body spellings agree (TestWorkItems_KeyInHeaderOrBodyIsTheSameRequest), ResumeWorkItem builds its response from what Resume returned instead of re-reading, WORK_ITEMS_DISABLED goes through strconv.ParseBool, and surface-refs is in the forged-actor coverage.

The identity and authorization model holds up: principalOf keyed on how the caller authenticated, the forbidden-field scan before any decode, canSeeWorkItem re-evaluated per request, 404 rather than 403 for an invisible item, superseded_by gated on the caller's own visibility, and Store.List failing closed when Tenants is empty and AllTenants is unset. Reads sit outside the 20/min write group and mutations inside it, as the contract specifies.

Remaining P3s (none blocking, all inline): AttachWorkItemExecution and TransitionWorkItem audit on a pure idempotent replay where the other four handlers gate on created; the surface-refs 400 on an Idempotency-Key header is absent from the OpenAPI that declares that header on every other work-item POST; the request digest canonicalises only the top level, so a nested params object re-serialised in a different key order refuses a genuine retry; and a literal null body passes the body-must-be-an-object guard (harmless today, every route still 400s on a required field).

Note: go build and go test were denied in this sandbox, so this is static review; CI supplies TEST_DATABASE_URL, so the Postgres-backed handler tests run rather than skipping.

Base automatically changed from feat/349-workitems-store to main September 23, 2026 09:03
@mashkovd
mashkovd dismissed claude[bot]’s stale review September 23, 2026 09:03

The base branch was changed.

…ede edges

Review P3s from #351:
- FOR UPDATE on the external_key lookup, so a concurrent transition cannot
  make the matched item terminal before the create commits.
- Events and Executions say an unknown id answers an empty list.
- Supersede says the successor may itself be terminal.
- The schema-upgrade test restores the dropped columns on cleanup.

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 3m 39s —— View job


Review posted — approved (0 P1, 0 P2, 4 P3)

  • Gather context (pinned diff 2557a89...60dfd69, store API, auth helpers, router, OpenAPI)
  • Review internal/api/handlers_work_items.go
  • Review router wiring, cmd/api/main.go, OpenAPI
  • Review test coverage
  • Post inline findings + verdict

Two inline comments are on the diff; the verdict is submitted against 60dfd69.

New in this commit

Both changes are real improvements:

  • Store.Create locks the external_key match (store.go:273). The per-tenant advisory lock already serialises concurrent creates, but not a concurrent Transition on the matched item: without the row lock a create could dedupe onto an item a committing transition had just taken terminal, handing the caller closed work in answer to an "open work" request. Under READ COMMITTED the locked re-read re-evaluates state IN ('active','waiting'), so that item drops out and a fresh one is opened — which the partial unique index work_items_tenant_external_key_open permits, since it only covers open states. No deadlock: Create holds workitem-create:<tenant> and waits on a row lock, while Transition/Resume/AttachExecution hold workitem:<id> and never wait on the tenant lock, so there is no cycle.
  • TestSchemaUpgradesAnEarlierRevision restores the dropped columns in t.Cleanup, matching the ADD COLUMN IF NOT EXISTS ... TEXT NOT NULL DEFAULT '' migration verbatim and registered after newStoreForTest, so LIFO runs it while the pool is still open. A failure there stays one failure instead of a half-dropped schema for every later test in the shared database.

The comments added to Transition (a supersede successor may itself be terminal) and to Executions/Events (an unknown id answers an empty list, not ErrNotFound, so the caller must Get first) pin exactly the two edges a reader would otherwise have to infer — and both handlers do Get first.

What holds up

Everything from the previous round survives re-reading: principalOf keyed on how the caller authenticated (service: / github: / oidc:), the forbidden-field scan before any decode on every mutating route, canSeeWorkItem re-evaluated per request, an invisible item answered 404 rather than 403, superseded_by gated on the caller's own visibility, and Store.List failing closed when Tenants is empty and AllTenants is unset — with TestWorkItems_ListWithoutTenantStaysInsideTheCallersTenants exercising both arms of that switch. The authorization model matches docs/work-context-contract.md §"Identity and authorization" as written. Mutations sit inside the 20/min write group and reads outside it, as the contract specifies.

P3 (none blocking)

Location Finding
handlers_work_items.go:541 AttachWorkItemExecution and TransitionWorkItem (:444) audit unconditionally; a pure replayed() hit writes a duplicate work_item.execution / work_item.transition row where the other four handlers gate on created. Closing it properly needs the store to report "nothing changed" — follow-up.
openapi.yaml:2718 The /surface-refs 400 on an Idempotency-Key header is absent from the spec, which declares that header on every other work-item POST.
handlers_work_items.go:228 requestDigestInput canonicalises only the top level; intentBody.Params is a json.RawMessage spliced in as sent, so the same params re-serialised in a different key order refuses a genuine retry with 409. Narrow in practice; the comment at :206 overclaims either way.
handlers_work_items.go:160 A literal null body passes the "body must be a JSON object" guard — json.Unmarshal into a map accepts it. Harmless today since every route then 400s on a required field; || keys == nil makes the guard mean what it says.

One caveat: go build and go test were denied in this sandbox, so this is static review — CI supplies TEST_DATABASE_URL (validate.yml:53) and runs -p 1, so the Postgres-backed handler tests run rather than silently skipping.
· branch feat/349-workitems-api

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 60dfd69.

Finding 1 (P2)

  • File: cmd/api/main.go, approx. line 1175
  • Anchor: + disabled, err := strconv.ParseBool(strings.TrimSpace(v))
  • Concrete Failure Scenario:
    .env.example documents: # Any WORK_ITEMS_DISABLED value turns it off (routes answer 503). and README.md documents: Kill switch: any value leaves the work-items store off (routes answer 503) without touching AUDIT_DB_URL for other stores.
    However, workItemsDisabled calls strconv.ParseBool(strings.TrimSpace(v)) and checks err == nil && disabled. strconv.ParseBool only returns true for "1", "t", "T", "true", "TRUE", and "True". Any other string—including standard operational truthy values like "yes" or "on", or descriptive kill-switch values like "disabled"—causes a parse error (err != nil), causing workItemsDisabled to return false.
    If an operator configures WORK_ITEMS_DISABLED=yes or WORK_ITEMS_DISABLED=disabled following common conventions or the documentation in .env.example/README.md, the kill switch silently fails to activate and work-item routes remain active instead of returning 503.
  • Proposed Fix:
    Update workItemsDisabled to treat any non-empty value that does not explicitly mean "false" as disabled:
    func workItemsDisabled(v string) bool {
    	v = strings.ToLower(strings.TrimSpace(v))
    	return v != "" && v != "0" && v != "false" && v != "no" && v != "off"
    }
    Alternatively, update the documentation in .env.example and README.md to specify that only boolean values are accepted, and expand workItemsDisabled to parse common values like "yes".

Finding 2 (P2)

  • File: internal/workitems/store.go, approx. line 406
  • Anchor: + next, err := getItem(ctx, tx, in.SupersededBy)
  • Concrete Failure Scenario:
    When executing a transition with action: "supersede", Store.Transition validates that the target in.SupersededBy item exists and belongs to the same tenant (next, err := getItem(...) and next.Tenant != cur.Tenant). However, neither handlers_work_items.go nor store.go checks whether in.SupersededBy == cur.ID.
    If a caller issues PATCH /api/v1/work-items/{id} with action: "supersede" and superseded_by: "{id}", getItem fetches the current item, verifies matching tenants, and transitions the item to superseded with superseded_by referencing itself. This creates an immediate self-referential cycle (item.superseded_by == item.id), corrupting successor resolution and work-item lifecycle history.
  • Proposed Fix:
    Add a validation check in Store.Transition inside the in.Action == ActionSupersede block (or in handlers_work_items.go) to disallow an item from superseding itself:
    if in.SupersededBy == cur.ID {
    	return invalid("work item %s cannot supersede itself", cur.ID)
    }

writeWorkItemError(w, err)
return
}
h.auditWorkItem(r, user, "work_item.execution", item.ID, item.Tenant,

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 (carried over, still open) — AttachWorkItemExecution and TransitionWorkItem (:444) are the two mutating handlers that audit unconditionally; CreateWorkItem (:340), AppendWorkItemIntent (:480), ResumeWorkItem (:582) and LinkWorkItemSurface (:635) all gate on created.

A client retrying POST .../executions or PATCH /{id} with the same Idempotency-Key gets a pure store-level replay — replayed() returns early, nothing is written, no event is appended — yet each attempt still emits a work_item.execution / work_item.transition audit row. N retries behind a flaky proxy become N audit entries for one event, so the audit log stops being a usable answer to "how many times did this actually change".

created alone is not the right gate for attach (a genuine phase advance also returns created == false and is worth auditing) and Transition returns no such flag at all, so closing it properly means the store reporting "nothing changed" alongside its current returns. Follow-up rather than a reshuffle here.

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.

Agreed it needs the store to report a replay; tracked as #355 rather than reshuffled here.

Records a surface-native conversation for reply routing, or refreshes
its last_seen_at. `actor_external_id` is correlation metadata only: it
is never used as, or resolved to, the acting principal (that needs
the surface-identity binding, mctl-api#350).

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 (carried over, still open) — the published contract for this route does not mention Idempotency-Key, but LinkWorkItemSurface (internal/api/handlers_work_items.go:619) answers 400 invalid_request when the header is present.

Idempotency-Key is declared as a header parameter on every other work-item POST (/work-items, /intents, /executions, /resume), so a generated client or an HTTP layer that stamps it uniformly on all POSTs — a common SDK default, and the pattern the other four routes teach — gets a hard failure on this one route with nothing in the spec to explain it. TestWorkItems_SurfaceRefsRefuseAnIdempotencyKeyAndAForgedActor pins the behaviour; only the contract is silent.

One line in the description above (the handler's message text is already the right wording) makes it discoverable before a client hits it in production.

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 609f3f3: the surface-refs description now says the route takes no Idempotency-Key and answers 400 to one.

claude[bot]
claude Bot previously approved these changes Sep 23, 2026

@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 (4 P3). Good to merge.

New in 60dfd69, and both are real improvements:

  • Store.Create now takes FOR UPDATE on the external_key lookup. The per-tenant advisory lock already serialises concurrent creates, but not a concurrent Transition on the matched item: without the row lock, a create could dedupe onto an item that a committing transition had just taken terminal, handing the caller closed work in answer to an "open work" request. Under READ COMMITTED the locked re-read re-evaluates state IN (active,waiting), so that item correctly drops out and a fresh one is opened — which the partial unique index work_items_tenant_external_key_open permits, since it only covers open states. No deadlock is introduced: Create holds workitem-create:<tenant> and waits on a row lock, while Transition/Resume/AttachExecution hold workitem:<id> and never wait on the tenant lock, so there is no cycle.
  • TestSchemaUpgradesAnEarlierRevision now restores the dropped actor columns in t.Cleanup (matching the ADD COLUMN IF NOT EXISTS ... TEXT NOT NULL DEFAULT ... migration verbatim, and registered after newStoreForTest so LIFO runs it while the pool is still open). A failure there stays one failure instead of leaving a half-dropped schema for every later test in the shared database.

The comments added to Transition (supersede may point at a terminal item) and to Executions/Events (an unknown id answers an empty list, not ErrNotFound, so the caller must Get first) document exactly the two edges a reader would otherwise have to infer; the handlers do Get first in both cases.

Everything checked in the previous round still holds: the principal comes only from auth.UserFromContext and is keyed on how the caller authenticated; the forbidden-field scan runs before any decode on every mutating route; canSeeWorkItem is re-evaluated per request and an invisible item answers 404, not 403; superseded_by is gated on the caller own visibility; Store.List fails closed when Tenants is empty and AllTenants is unset, and TestWorkItems_ListWithoutTenantStaysInsideTheCallersTenants exercises both arms of that switch. The authorization model matches docs/work-context-contract.md "Identity and authorization" as written. Mutations sit inside the 20/min write group and reads outside it, as the contract specifies.

Remaining P3s, none blocking:

  1. AttachWorkItemExecution (:541) and TransitionWorkItem (:444) audit unconditionally; a pure replayed() hit writes a duplicate work_item.execution / work_item.transition row where the other four handlers gate on created. Closing it properly needs the store to report "nothing changed" — follow-up. (inline)
  2. The /surface-refs 400 on an Idempotency-Key header is absent from openapi.yaml, which declares that header on every other work-item POST. (inline)
  3. requestDigestInput canonicalises only the top level; intentBody.Params is a json.RawMessage spliced in as sent, so the same params re-serialised in a different key order refuses a genuine retry with 409. Narrow in practice; the comment at :206 overclaims either way.
  4. A literal null body passes the "body must be a JSON object" guard (:160) — json.Unmarshal into a map accepts it. Harmless today since every route then 400s on a required field; || keys == nil makes the guard mean what it says.

Caveat: go build and go test were denied in this sandbox, so this is static review. CI supplies TEST_DATABASE_URL (validate.yml:53) and runs -p 1, so the Postgres-backed handler tests run rather than silently skipping.

…e-refs key

- The kill switch turns work items off for any value except an explicit
  false/0/no/off: a boolean parse made "yes" or "disabled" silently
  leave them on, contrary to the README and .env.example.
- OpenAPI: surface-refs takes no Idempotency-Key and answers 400 to one.

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

Copy link
Copy Markdown
Member Author

agy P2 on 60dfd69 (WORK_ITEMS_DISABLED): real — the README and .env.example promise "any value" turns work items off, and the boolean parse made yes/disabled silently leave them on. Fixed in 609f3f3: a kill switch errs toward off, so anything except false/0/no/off/empty disables; docs say exactly that; the unit test covers yes, on and disabled (fails when yes is treated as false).

@claude

claude Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 51s —— View job


Review posted — approved (0 P1, 0 P2, 4 P3)

  • Gather context (pinned diff 2557a89...609f3f3, store API, auth helpers, router, OpenAPI)
  • Review internal/api/handlers_work_items.go
  • Review router wiring, cmd/api/main.go, OpenAPI
  • Review test coverage
  • Post inline findings + verdict

One inline comment is on the diff; the verdict is submitted against 609f3f3.

New in this commit

  • WORK_ITEMS_DISABLED errs toward off (cmd/api/main.go:1184). Any value except false/0/no/off (or empty) now turns work items off, which is what README.md and .env.example already claimed and what the agy P2 called out — a strconv.ParseBool left yes or disabled silently enabled, so an operator reaching for the kill switch mid-incident would have found the routes still live. The table test pins yes/on/disabled as disabling and ""/false/0/no/off/" FALSE " as keeping them on. strings is still needed in main.go and strconv is still used at :1095, so no unused import.
  • /surface-refs documents its Idempotency-Key rejection (openapi.yaml:2719), closing the other carried-over P3: the route is idempotent by (surface, external_id) and answers 400 to that header, which the spec previously did not say while declaring the header on every other work-item POST.

Not reproduced

The agy P2 on self-supersede is not reachable. TransitionInput.validate (internal/workitems/inputs.go:208) rejects SupersededBy == WorkItemID before the store transaction opens, so superseded_by cannot point at its own id and no self-referential cycle can be written.

What holds up

The identity and authorization model survives re-reading: principalOf keyed on how the caller authenticated (service: / github: / oidc:), the forbidden-field scan before any decode on every mutating route, canSeeWorkItem re-evaluated per request, an invisible item answered 404 rather than 403, superseded_by gated on the caller's own visibility, and Store.List failing closed when Tenants is empty and AllTenants is unset. Reads are registered inside the authenticated group and outside the 20/min write budget; mutations are inside it, as the contract specifies. The openapi.yaml quoting fix plus TestSpecParses remains a real catch — that file did not parse before.

P3 (none blocking)

Location Finding
cmd/api/main.go:1186 f/F flipped meaning versus the ParseBool one commit ago, so WORK_ITEMS_DISABLED=f now disables. Adding "f" to the false arm makes the set a superset of both spellings. TestWorkItemsDisabledParsesABoolean also no longer parses a boolean — stale name, good table.
handlers_work_items.go:541, :444 AttachWorkItemExecution and TransitionWorkItem audit unconditionally; a pure replayed() hit writes a duplicate work_item.execution / work_item.transition row where the other four handlers gate on created. Needs the store to report "nothing changed" — follow-up.
handlers_work_items.go:228 requestDigestInput canonicalises only the top level; intentBody.Params is a json.RawMessage spliced in as sent, so the same params re-serialised in a different key order refuses a genuine retry with 409.
handlers_work_items.go:160 A literal null body passes the "body must be a JSON object" guard — json.Unmarshal into a map accepts it. Harmless today since every route then 400s on a required field.

One caveat: go build and go test were denied in this sandbox, so this is static review — CI supplies TEST_DATABASE_URL (validate.yml:53) and runs -p 1, so the Postgres-backed handler tests run rather than silently skipping.
· branch feat/349-workitems-api

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 609f3f3.

No significant issues found.

Comment thread cmd/api/main.go Outdated
// "false" keeps them on while "yes" or "disabled" never silently fails open.
func workItemsDisabled(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "false", "0", "no", "off":

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 inversion is the right call for a kill switch, but two values that strconv.ParseBool (the previous implementation, one commit ago) read as false now read as disabled: f and F. A WORK_ITEMS_DISABLED=f that meant "off" under 60dfd69 silently takes every /api/v1/work-items route to 503 here, with only a slog.Warn to show for it — the exact failure mode this commit set out to remove, just in the other direction.

Unlikely in practice, and erring toward off is the safer half of the trade, so this is a nit. Adding "f" to the false arm costs nothing and makes the set a superset of both spellings the flag has had:

case "", "false", "f", "0", "no", "off":

Separately, TestWorkItemsDisabledParsesABoolean no longer describes what it tests — the function deliberately stopped parsing a boolean. The table itself is good (yes, on, disabled all pin the new behaviour); only the name is stale.

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 03012b8: f is in the false arm again, and the test is renamed and turned into a case table (which also clears the gocritic whitespace-key lint failure).

claude[bot]
claude Bot previously approved these changes Sep 23, 2026

@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 (4 P3). Good to merge.

New in this commit: the WORK_ITEMS_DISABLED kill switch now errs toward off (any value except false/0/no/off), which matches what README.md and .env.example say and closes the agy P2 — a boolean parse left "yes" or "disabled" silently enabled. The table test pins yes/on/disabled as disabling and ""/false/0/no/off/" FALSE " as keeping it on. The /surface-refs description now documents that the route takes no Idempotency-Key and answers 400 to one, which was the other carried-over P3. strings is still needed in main.go and strconv is still used at :1095, so no unused import.

Checked the agy P2 on self-supersede: not reachable. TransitionInput.validate (internal/workitems/inputs.go:208) already rejects SupersededBy == WorkItemID before the store runs, so superseded_by cannot point at its own id.

The identity and authorization model holds up on re-reading: principalOf keyed on how the caller authenticated (service:/github:/oidc:), the forbidden-field scan before any decode on every mutating route, canSeeWorkItem re-evaluated per request, an invisible item answered 404 rather than 403, superseded_by gated on the caller's own visibility, and Store.List failing closed when Tenants is empty and AllTenants is unset. Reads are inside the authenticated group and outside the 20/min write budget; mutations are inside it, as the contract specifies. The openapi.yaml quoting fix plus TestSpecParses remains a real catch — that file did not parse before.

P3 (none blocking):

  • cmd/api/main.go:1186 — "f"/"F" flipped meaning versus the previous ParseBool implementation, so WORK_ITEMS_DISABLED=f now disables; TestWorkItemsDisabledParsesABoolean no longer parses a boolean.
  • handlers_work_items.go:541 and :444 — AttachWorkItemExecution and TransitionWorkItem audit unconditionally; a pure replayed() hit writes a duplicate work_item.execution / work_item.transition row where the other four handlers gate on created. Needs the store to report "nothing changed"; follow-up.
  • handlers_work_items.go:228 — requestDigestInput canonicalises only the top level; intentBody.Params is a json.RawMessage spliced in as sent, so the same params re-serialised in a different key order refuses a genuine retry with 409.
  • handlers_work_items.go:160 — a literal null body passes the "body must be a JSON object" guard; harmless today since every route then 400s on a required field.

Caveat: go build and go test were denied in this sandbox, so this is static review — CI supplies TEST_DATABASE_URL (validate.yml:53) and runs -p 1, so the Postgres-backed handler tests run rather than silently skipping.

gocritic flagged a whitespace map key. "f" keeps work items on, as it did
under the boolean parse one commit ago.

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 1m 56s —— View job


Review posted — approved (0 P1, 0 P2, 5 P3)

  • Gather context (pinned diff 2557a89...03012b8, store API, auth helpers, router, OpenAPI)
  • Review internal/api/handlers_work_items.go
  • Review router wiring, cmd/api/main.go, OpenAPI
  • Review test coverage
  • Post inline findings + verdict

One inline comment is on the diff; the verdict is submitted against 03012b8.

New in this commit

workItemsDisabled accepts f/F as false again, so the false arm is now a superset of both spellings the flag has had: a WORK_ITEMS_DISABLED=f that meant "off" under the strconv.ParseBool implementation no longer flips to disabling every /api/v1/work-items route, while yes/on/disabled still cannot silently leave the kill switch un-engaged. That closes the last round's P3 in the direction that keeps both failure modes out.

TestWorkItemsDisabledParsesABoolean → TestWorkItemsDisabledErrsTowardOff: the name now describes what the function does (it deliberately stopped parsing a boolean), and the map became a case table — which also clears the gocritic whitespace-key lint. The table pins ""/false/f/F/0/no/off/" FALSE\t" as keeping work items on and true/1/TRUE/yes/on/disabled as turning them off, so either arm regressing fails the test.

What holds up

Re-reading the full range, everything from the previous rounds survives: principalOf keyed on how the caller authenticated (service: / github: / oidc:); the forbidden-field scan running before any decode on every mutating route; canSeeWorkItem re-evaluated per request with an invisible item answered 404 rather than 403; superseded_by gated on the caller's own visibility; and Store.List failing closed when Tenants is empty and AllTenants is unset, with both arms of the tenancy switch exercised by TestWorkItems_ListWithoutTenantStaysInsideTheCallersTenants. Mutations sit inside the 20/min write group and reads outside it, as docs/work-context-contract.md specifies. Store.Create's FOR UPDATE on the external_key match and the schema-upgrade test's column restore — registered after newStoreForTest so LIFO runs it while the pool is open, and matching the migration's ADD COLUMN IF NOT EXISTS ... DEFAULT '' verbatim — are both still correct.

P3 (none blocking)

Location Finding
README.md:138, .env.example:23 Both say "any value except false/0/no/off", which no longer matches the code now that f/F is false — by the documented rule WORK_ITEMS_DISABLED=f turns work items off, while the code leaves them on. The divergence errs toward keeping routes up, not toward a switch that fails open. The function comment at cmd/api/main.go:1181 already lists f; only the operator-facing docs are behind. (inline)
handlers_work_items.go:541, :444 AttachWorkItemExecution and TransitionWorkItem audit unconditionally; a pure replayed() hit writes a duplicate work_item.execution / work_item.transition row where the other four handlers gate on created. Tracked as #355.
handlers_work_items.go:228 requestDigestInput canonicalises only the top level; intentBody.Params is a json.RawMessage spliced in as sent, so the same params re-serialised in a different key order refuses a genuine retry with 409.
handlers_work_items.go:160 A literal null body passes the "body must be a JSON object" guard — json.Unmarshal into a map accepts it. Harmless today since every route then 400s on a required field; || keys == nil makes the guard mean what it says.
handlers_work_items.go:367 ListWorkItems does not validate the state query parameter at the HTTP layer; Store.List rejects an unknown one with ErrInvalid, so the outcome is a correct 400 — noted only because limit and tenant are validated in the handler and state is not.

One caveat: go build and go test were denied in this sandbox, so this is static review. CI supplies TEST_DATABASE_URL (validate.yml:53) and runs -p 1, so the Postgres-backed handler and store tests run rather than silently skipping.
· branch feat/349-workitems-api

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 03012b8.

No significant issues found.

Comment thread README.md
| `AUDIT_DB_URL` | PostgreSQL connection string (falls back to in-memory). `sslmode=disable` is upgraded to `require`, or `verify-full` when a CNPG CA is mounted. | — | No |
| `HUMAN_INPUT_DB_URL` | PostgreSQL for the human-input delivery ledger (`POST /api/v1/human-input/{request_id}/response`). Falls back to `AUDIT_DB_URL`; with neither, that endpoint answers 503 (there is no in-memory fallback). The raw answer is kept only until its delivery resolves, or until the request expires. | — | No |
| `WORK_ITEMS_DB_URL` | PostgreSQL for the `workitem/v1` store (`/api/v1/work-items*`). Falls back to `AUDIT_DB_URL`; with neither, every work-items route answers 503. | — | No |
| `WORK_ITEMS_DISABLED` | Kill switch: any value except `false`/`0`/`no`/`off` leaves the work-items store off (routes answer 503) without touching `AUDIT_DB_URL` for other stores. | unset | No |

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 operator-facing docs now enumerate a set the code no longer matches. workItemsDisabled (cmd/api/main.go:1185) treats f/F as false, but this row and .env.example:23 both say "any value except false/0/no/off" — so by the documented rule WORK_ITEMS_DISABLED=f turns work items off, while the code leaves them on.

Harmless in direction (the divergence errs toward keeping the routes up, not toward a kill switch that silently fails open), and f is an unlikely thing to write by hand. But it is the same doc-vs-code gap the previous round closed in the other direction, so it is worth keeping the two in step: add `f` to both lists, or drop f from the code arm and keep the docs as the single spelling of the rule.

The function comment at cmd/api/main.go:1181 already lists "false"/"f"/"0"/"no"/"off" correctly; only the docs an operator actually reads are behind.

@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 (5 P3). Good to merge.

New in 03012b8: workItemsDisabled accepts "f"/"F" as false again, so the set is now a superset of both spellings the flag has had — a WORK_ITEMS_DISABLED=f that meant "off" under the previous ParseBool implementation no longer flips to disabling every /api/v1/work-items route. TestWorkItemsDisabledErrsTowardOff is renamed to describe what it actually tests and converted to a case table (which also removes the gocritic whitespace-key issue); it pins ""/false/f/F/0/no/off/" FALSE\t" as keeping work items on and true/1/TRUE/yes/on/disabled as turning them off, so both halves of the kill-switch rule fail if either arm regresses.

Everything checked in earlier rounds still holds on re-reading: principalOf keyed on how the caller authenticated (service:/github:/oidc:), the forbidden-field scan before any decode on every mutating route, canSeeWorkItem re-evaluated per request, an invisible item answered 404 rather than 403, superseded_by gated on the caller's own visibility, and Store.List failing closed when Tenants is empty and AllTenants is unset (with both arms of the tenancy switch exercised). Mutations sit inside the 20/min write group and reads outside it, as the contract specifies. Store.Create's FOR UPDATE on the external_key match and the schema-upgrade test's column restore (registered after newStoreForTest, so LIFO runs it while the pool is open, and matching the migration's ADD COLUMN IF NOT EXISTS ... DEFAULT '' verbatim) are both still correct.

Remaining P3s, none blocking:

  1. README.md:138 and .env.example:23 say "any value except false/0/no/off", which no longer matches the code now that f/F is false. The divergence errs toward keeping routes up, not toward a switch that fails open. (inline)
  2. AttachWorkItemExecution (:541) and TransitionWorkItem (:444) audit unconditionally; a pure replayed() hit writes a duplicate work_item.execution / work_item.transition row where the other four handlers gate on created. Tracked as #355.
  3. requestDigestInput canonicalises only the top level; intentBody.Params is a json.RawMessage spliced in as sent, so the same params re-serialised in a different key order refuses a genuine retry with 409.
  4. A literal null body passes the "body must be a JSON object" guard (:160) — json.Unmarshal into a map accepts it. Harmless today since every route then 400s on a required field; || keys == nil makes the guard mean what it says.
  5. ListWorkItems does not validate the state query parameter at the HTTP layer; the store rejects an unknown one with ErrInvalid, so the outcome is a correct 400 — noted only because limit and tenant are validated in the handler and state is not.

Caveat: go build and go test were denied in this sandbox, so this is static review. CI supplies TEST_DATABASE_URL (validate.yml:53) and runs -p 1, so the Postgres-backed handler and store tests run rather than silently skipping.

@mashkovd
mashkovd merged commit 4786233 into main Sep 23, 2026
10 checks passed
@mashkovd
mashkovd deleted the feat/349-workitems-api branch September 23, 2026 09:27
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(work-context): implement canonical workitem/v1 API

2 participants