Skip to content

Fix the init→ladder happy path found by simulation - #30

Merged
somebox merged 6 commits into
mainfrom
fix/happy-path-simulation
Oct 12, 2026
Merged

somebox merged 6 commits into
mainfrom
fix/happy-path-simulation

Conversation

@somebox

@somebox somebox commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

  • A fresh cards init workspace could create cards as me but not claim them: definition-declared users never reached the users table, and the ownership check only consulted that table. checkUserExists now accepts declared users, the workspace snapshot merges registered + declared, and the unknown_user hint names cards users register.
  • Quiet list -q on an empty collection printed {"items":[]}, which a list -q | while read id loop would feed to the next command. Quiet mode now keys on the presence of items, not its length.
  • project-practices.md now includes the five ladder JSON files (it previously told the reader to copy them but only shipped a settings snippet). Board snapshot closes the related cards; sprint note for 09-06 and a cards-8080 launch config land with it.

Test plan

  • go test ./internal/core ./internal/cli ./internal/docaudit
  • Fresh cards init workspace: create a card as the declared default user, then claim it (cards take / owner patch) without registering first
  • cards list -q on an empty board prints nothing (no {"items":[]})
  • Ladder files in internal/agentguide/skill/references/project-practices.md §2 match a working epic/story/task init

Made with Cursor

somebox and others added 4 commits September 5, 2026 15:24
Walking a stranger through `cards init` → replace the starter with the
epic/story/task ladder → create, link, claim, export surfaced three gaps:

- Definition-declared users (workspace.users, or the settings.default_user
  seed) never reach the users table, and the ownership check only consulted
  the table. A fresh `cards init` workspace's `me` could create cards but
  not claim them. checkUserExists now accepts declared users, the workspace
  snapshot merges registered + declared, and the unknown_user hint names
  `cards users register` alongside POST /v1/users.
- `list -q` on an empty collection printed `{"items":[]}`, which a
  `list -q | while read id` loop would feed straight to the next command.
  Quiet mode now keys on the presence of `items`, not its length.
- project-practices.md told the reader to "write the JSON in this document"
  but carried only a settings snippet. §2 now has the five ladder files,
  extracted and verified against a fresh init end to end.

Re-pins the two claimWithRetry doc-audit anchors the service edit moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…review

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two threads. Thread 1 finishes Sprint A (069ec1d1, c0102825) plus the
TakeNext owner check (1c877e6c, promoted to todo) and one new integration
test card proving the three-call loop. Thread 2 files the README/site
refresh as a parent card with four children (visuals, home + nav, README
cut, using-cards split) in backlog, sequenced so the text lands after the
CLI flags it documents. Closes ea7ea2a3 as already shipped.

Adds a cards-8080 launch config for serving the project board by name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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.

🟡 Changes recommended

Default-user ownership remains inconsistent for workspaces with explicit users, and two new documentation cards omit declared dependency links.

2 open findings
What changed in this PR

Fixes fresh-workspace ownership and empty quiet-list behavior, while updating adoption guidance and project planning data.

Changes:

  • Accepts definition-declared users for ownership and merges them into workspace introspection.
  • Prevents empty collections from leaking JSON in quiet CLI output.
  • Adds ladder configuration examples, planning notes, and launch configuration.
File Description
mkdocs.yml Registers the new sprint plan.
internal/​core/​validate.go Accepts declared users as owners.
internal/​core/​service.go Merges stored and declared users.
internal/​core/​errors.go Improves unknown-user recovery guidance.
internal/​core/​declaredusers_test.go Tests declared-user ownership.
internal/​cli/​client.go Handles empty quiet collections.
internal/​cli/​cli_test.go Covers empty quiet output.
internal/​agentguide/​skill/​references/​project-practices.md Adds complete ladder definitions.
docs/​reference/​INTEGRATOR-REFERENCE.md Documents declared owners.
docs/​reference/​implementation-status.md Updates implemented ownership behavior.
docs/​plans/​2026-09-06-sprint-plan.md Adds the sprint plan.
.claude/​launch.json Adds an 8080 launch target.
.cards/​backlog.jsonl Updates and adds project cards.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/core/validate.go
Comment on lines 378 to +383
func (s *Service) checkUserExists(ctx context.Context, userID string) error {
for _, u := range s.ws.Users {
if u.ID == userID {
return nil
}
}
Comment thread .cards/backlog.jsonl Outdated
@@ -66,6 +68,7 @@
{"data":{"id":"card_3fd62d3280aa427faeaf6253b687c4c9","workspace_id":"demo","type_id":"api-task","schema_version":1,"title":"POST /v1/cards/query — JSON filter over HTTP (decided contract + endpoint)","status":"todo","fields":{"acceptance":"1) POST /v1/cards/query accepts a JSON filter body and returns the same page shape as GET /v1/cards; demo: curl resolves a card by its `branch` field with no client-side filtering. 2) Scope stays simple — filters, pagination, basic string matching. No new operators, no complexity bounds, no body cap. 3) A malformed filter still returns the existing structured 422 carrying `field: \"filter\"` (this already works via the take-next path; the new route must not lose it). 4) BOARD SCOPE stated plainly and contract-tested: board `default_filter` is ANDed with any caller filter and never widened, while explicit `type_id`/`status` REPLACE the board's type/column defaults — a test pins that board_id=X plus a status outside X.columns returns those cards, because a board is a view over the cards, not a fence around them. Docs note the mitigation for anyone wanting a hard boundary: put `type_id $in [...]` in default_filter. 5) Handler is NOT wrapped in withActor, matching GET /v1/cards. 6) Docs: query-dsl.md documents the endpoint and records GET ?filter= as declined for encoding and log leakage; api-surface.md gains the endpoint bullet; the implementation-status.md endpoint table lists it. 7) Route, internal/openapi/openapi.go paths() entry and the implementation-status table land in ONE commit — TestOpenAPICoversEveryRoute passes in both directions.","api_change":"additive","branch":"feat/cards-query-endpoint","description":"POST /v1/cards/query with a JSON filter body (declining GET ?filter=). Thin handler -> Service.ListCards; filter compiles in internal/sqlite/filter.go. Originally Sprint 07-22 Phase 2 (docs/plans/2026-07-22-sprint-plan.md) — committed there and never shipped; carried into the http-integration-surface sprint.\n\nScope of the endpoint: **simple queries only** — filters, pagination, basic string matching. It exposes the DSL that already exists; it does not grow one.\n\n## LOCKED DECISIONS (2026-08-08 sprint planning — do not reopen mid-implementation)\n\n**1. BOARD SCOPE IS A VIEW, NOT A FENCE. The old acceptance line was wrong and has been rewritten.**\n\nCards are the source of truth; boards are views on top of them. Boards and cards are not handed out separately — they are parts of one workspace. So a board's column and type lists are DEFAULTS for a query, and naming `type_id` or `status` explicitly replaces them. That is correct behavior for a view, not a leak.\n\nWhat the code actually does — `applyBoardScope` (internal/core/service.go:579-600) folds board scope three ways, and only ONE is a hard AND:\n- `TypeIDIn = b.CardTypeIDs` only if the caller set neither `TypeIDIn` nor `TypeID` (:582-584)\n- `StatusIn = b.Columns` only if the caller set neither `Status` nor `StatusIn` (:587-589)\n- `DefaultFilter` — UNCONDITIONAL AND (:593-599)\n\nThe previous acceptance criterion promised \"narrow-never-widen board isolation\" across all three. That describes a fence, which is not what a board is.\n\nDo NOT change applyBoardScope here. Tightening it to intersect would change GET /v1/cards, MCP list and the TUI, and it carries a live footgun: an empty intersection must yield an empty page, but `TypeIDIn = []` is read by the store as NO FILTER (warning at internal/core/service.go:1524-1526), so a careless intersect WIDENS scope to the whole workspace.\n\nThe contract test states the real behavior plainly: board_id=X plus a status outside X.columns returns those cards. A client author needs to know this, and today nothing tells them. Docs note that a board author who wants a hard type/column boundary puts `type_id $in [...]` / `status $in [...]` in `default_filter`, which already works.\n\n**2. NO COMPLEXITY BOUNDS, NO REQUEST-BODY CAP.** An earlier draft of this card locked filter depth/node/array limits and an http.MaxBytesReader body cap. Both are removed: this is a local, single-tenant, trusted service (philosophy principle 7, YOLO defaults) and hardening it against a hostile caller is permission theater. Keep it simple. If a deployment ever needs limits, that is extension territory — a proxy in front, or an extension — not core.\n\n**3. NO `withActor` WRAPPER.** Symmetry with GET /v1/cards, which is unwrapped: reads do not need actor context. (Do not justify this as \"it would 403\" — withActor only rejects when resolveActor comes back empty across header, env AND default_user; take-next uses it without trouble.)\n\n**4. `GET ?filter=` IS DECLINED, WITH A STATED REASON.** docs/spec/query-dsl.md:29-38 currently says it is \"not currently wired\" — a statement about wiring, not a decision. The rationale for declining lives only in docs/plans/2026-07-22-sprint-plan.md: **encoding and log leakage** — a URL-encoded filter lands in access logs and referrers. Carry that one-liner into the doc so \"declined\" is not a naked editorial claim.\n\n**5. ATOMIC MERGE.** Route + `paths()` entry in internal/openapi/openapi.go + the docs/reference/implementation-status.md endpoint table land in ONE commit. TestOpenAPICoversEveryRoute (internal/httpapi/openapi_coverage_test.go:37) fails in BOTH directions — a documented phantom route or an undocumented real one — so a half-merged card turns CI red for everyone.\n\n## REUSE (verified this session)\n\n- `core.TakeNextRequest` (internal/core/types.go:527-534) is the precedent for a JSON-decodable filter carrier: `Filter map[string]any \\`json:\"filter,omitempty\"\\``. `CardQuery` (types.go:426-453) has NO json tags — it is programmatic only, so a new request struct is required. CardQuery.Filter is at types.go:447.\n- `POST /v1/cards/take-next` (internal/httpapi/server.go:275) proves a static path coexists with /v1/cards/{id} under chi; there is not even a method collision.\n- Structured 422 for a malformed filter already works and is already tested: `core.NewValidationError` (internal/core/errors.go:38); end-to-end example to copy is TestTakeNextMalformedFilterIs422 (internal/httpapi/filter_test.go:10-21); the store-level assertion shape (Code `validation_failed`, Field `filter`) is internal/sqlite/filter_test.go:81-114. Nothing new is needed here — just do not lose it on the new route.\n- openapi.go's nearest template is the /cards/take-next entry (openapi.go:78-88), which already documents a `filter` object with the operator list.\n\n## FIXTURE GAP (the card's named test does not exist)\n\nThe card said `TestBoardDefaultFilter`. The real test is **TestBoardDefaultFilterScope** (internal/core/service_test.go:989-1015), and its `hipri` board is an in-process `core` fixture (service_test.go:61-66) — not reachable from package httpapi_test. NO board with a `default_filter` exists in examples/demo-workspace or .cards/definitions. An HTTP-level scope test therefore needs a new fixture; cheapest is the in-memory `newOffBoardTransitionServer` pattern (internal/httpapi/httpapi_test.go:48-83), NOT a new demo-workspace board (that would ripple into internal/httpapi/render_golden_test.go).\n\nSmall stale comments to fix while here: internal/sqlite/filter.go:16 and :98 say malformed DSL surfaces as \"HTTP 400\"; it is 422 (core/errors.go:38-40). internal/openapi/openapi.go:53 names a nonexistent test `TestPathsCoverEveryRoute` (real name: TestOpenAPICoversEveryRoute).\n\n## OUT OF SCOPE\n\nChanging applyBoardScope. Any query capability beyond filter + pagination + basic string matching. Any CLI surface for the endpoint — demo is curl. Rate limits, body caps, complexity bounds.\n\nNote: docs/spec/data-model.md:198-199 claims `card_type_ids` \"is merged into default_filter as type_id $in [...]\" — the code does not do this, and per decision 1 the code is right. That is a spec drift, recorded on the spec-drift card (2f12f16f), not here.\n\n## SHRINK VALVE\n\nThis card is the sprint's shrink valve. If capacity runs short it is cut and left linked-but-unstarted; health (fc92019c) and OpenAPI publication (8afb9008) carry the sprint's thesis. Cutting it breaks no existing client — the DSL is documented as not wired over HTTP today.","endpoint":"POST /v1/cards/query","verify":"go test ./internal/httpapi -run 'CardsQuery|OpenAPICoversEveryRoute'"},"links":[{"type_id":"related","target":"card_4c5326d4395745aa98eb241d4361be31","note":"carry-over: committed to Sprint 07-22 under this tracker, never shipped; parent is now the http-integration-surface tracker","created_by":"claude","created_at":"2026-08-08T09:50:19.133269Z"}],"comments":[{"id":"cm_873f89e9908642a1","author":"claude","body":"**Scope locked 2026-08-08 (sprint planning, http-integration-surface). Ships SECOND, and is the sprint's shrink valve.**\n\n**The acceptance criterion was rewritten because it described behavior this service does not have.** It promised \"narrow-never-widen board isolation, contract-tested\". Reading applyBoardScope (internal/core/service.go:579-600): only the `default_filter` leg is an unconditional AND (:593-599). The type and column legs are DEFAULTS — applied only if the caller set nothing (:582-589) — so an explicit `status` or `type_id` replaces the board's scope. `board_id=hipri&status=archived` returns cards in a status that is not a hipri column, today, on GET /v1/cards.\n\nWhy we are not fixing that here:\n- It is not a privilege escape. No auth exists; the same caller can GET /v1/cards with no board_id and see everything. A board is a lens, not a boundary (docs/spec/data-model.md:167).\n- Fixing it changes GET /v1/cards, MCP list and the TUI — a behavior-change sprint smuggled into a legibility sprint.\n- The naive fix is dangerous: an empty intersection must produce an empty page, but `TypeIDIn = []` is read by the store as *no filter* (warning at service.go:1524-1526). A botched intersect WIDENS scope to the whole workspace.\n\nSo the test pins the escape as contract instead. That is the honest move for a sprint whose thesis is \"you can build a client without reading Go\" — a client author needs to know this, and today nothing tells them.\n\nRelated spec/code conflict, filed separately, do NOT resolve here: docs/spec/data-model.md:198-199 claims `card_type_ids` \"is merged into default_filter as type_id $in [...]\". If that were literally true the type leg WOULD be a hard AND. The code does not do it. Whichever side moves needs its own changelog entry.\n\nCorrections to the card's own text, verified this session: the named test `TestBoardDefaultFilter` does not exist — it is `TestBoardDefaultFilterScope` (internal/core/service_test.go:989), and its hipri fixture is in-process to package core. No board with a default_filter exists in ANY loadable workspace, so the HTTP-level test needs a new fixture. CardQuery.Filter is types.go:447 (the 07-22 plan says :406); applyBoardScope is service.go:579-600 (the plan says :568-586).\n\nDSL bounds now carry numbers (depth 8 / nodes 64 / $in 100) so an implementer does not invent them mid-sprint, and they go in compileFilter so board filters and take-next get them too — not just this route.","created_at":"2026-08-08T09:48:12.505343Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_92fb37a54668479b","author":"claude","body":"**Citation correction (same session).** An earlier note here cited `docs/spec/data-model.md:167` for \"a board does not own cards\". That line is blank. The wording is at **docs/concepts/index.md:154** (\"A board is a Kanban lens over the workspace's cards. It does not own cards\") and is repeated at **docs/reference/workspace-and-boards.md:89**. The description has been corrected; the reasoning is unaffected.","created_at":"2026-08-08T09:51:26.533403Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_60e4100ff310439e","author":"claude","body":"**Scope corrected 2026-08-08 after review — two decisions removed.**\n\nAn earlier revision of this card locked filter complexity bounds (depth 8 / 64 nodes / 100-item $in) and an http.MaxBytesReader body cap. Both are **removed**. They were hardening against a hostile caller, which contradicts philosophy principle 7 (YOLO defaults — local-only, single-tenant, trusted; no permission theater). Queries here stay simple: filters, pagination, basic string matching. A deployment that genuinely needs limits puts a proxy in front or writes an extension — that is the documented escape hatch, and it keeps the core small.\n\nThe board-scope decision is unchanged but its reasoning is now the right one. The earlier draft argued from \"there is no auth so nothing is leaking\", which is a defensive framing. The actual reason: **cards are the source of truth and boards are views on top of them.** They are not handed out separately; they are parts of one workspace. So a board's columns and types are query defaults, and naming a status explicitly looks past them. That is what a view does.\n\nFollow-on: this also settles the docs/spec/data-model.md:198-199 conflict (it claims card_type_ids is merged into default_filter as a hard AND; the code treats it as a default). The code is right, the spec is drifted — recorded as a seventh item on the spec-drift card 2f12f16f rather than as its own card.","created_at":"2026-08-09T13:19:48.413908Z","edited_at":"0001-01-01T00:00:00Z"}],"version":13,"created_at":"2026-07-22T16:49:19.167876Z","updated_at":"2026-08-09T13:25:08.312141Z","created_by":"claude","status_since":"2026-07-22T16:49:19.167876Z"},"type":"card"}
{"data":{"id":"card_440a2bed167f4a769d4058aae5c16ccd","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"UI: theme contract — html[data-theme] hook + named themes + reference default","status":"done","fields":{"branch":"feat/ui","description":"Implement the theme contract documented in docs/DESIGN.md §Theming. Add the html[data-theme=\"<name>\"] hook (workspace/user-selectable named themes); a theme is a token remap + optional scoped rules on the stable hooks (:root tokens, [data-board], .card[data-type], component classes) and never requires markup changes. Class names and data-* attributes become a public API (renames = breaking). The default :root theme is the reference clean-UI implementation themes are judged against. Deliverables: data-theme plumbing (workspace setting -> <html> attr), one example alternate theme shipped as proof, DESIGN.md updated from 'planned' to implemented, and a fix for the half-dead badge contract (--badge-wash unconsumed; TypeTheme.Icon renders as literal text instead of a mask glyph)."},"tags":["feature"],"comments":[{"id":"cm_ecbdf9c4b4134c03","author":"jeremy","body":"Core delivered: html[data-theme] hook implemented (httpapi.resolveTheme — ?theme= sticky cookie + workspace settings.theme default; data-theme on <html> in layout.html, conditional web-font link). First named theme shipped as proof: 'journal' (handwritten/pastel/lined-notebook) — a full token remap + scoped component rules, no markup changes. DESIGN.md theme-contract table updated planned->implemented + named-themes section. RESIDUAL (still open): the badge contract cleanup this card also scoped — --badge-wash is emitted but unconsumed, and TypeTheme.Icon renders as literal text instead of a mask glyph.","created_at":"2026-07-02T14:46:02.11071718Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_0b640af5f3e440fb","author":"jeremy","body":"Badge-contract residual resolved (PR #5, merged): --badge-wash consumed, TypeTheme.Icon no longer literal text. Full card scope (html[data-theme] hook + journal theme + badge contract) now delivered. Moving to review.","created_at":"2026-07-02T15:41:16.11007224Z","edited_at":"0001-01-01T00:00:00Z"}],"version":6,"created_at":"2026-07-02T12:07:25.377357699Z","updated_at":"2026-07-04T22:01:47.511564897Z","created_by":"jeremy","status_since":"2026-07-04T22:01:47.511564897Z"},"type":"card"}
{"data":{"id":"card_4455eb345eca47e3836f31dc45611e6a","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"Land column-census COUNT(*) fix on main (e8b5565 stranded on core/column-census)","status":"done","fields":{"branch":"core/column-census","description":"Residual from card_d39fd119 (accurate column census, closed done 2026-07-12): the fix (Store.CountCards COUNT(*), countColumn switched off the Limit:500 ListCards scan) exists as commit e8b5565 — but ONLY on branch core/column-census; it is not an ancestor of main. On main, countColumn still undercounts >500-card columns.\n\nDO: rebase/cherry-pick e8b5565 onto current main (the sqlite/service split and b23aff8 have moved underneath it), re-run the regression (TestBreaches* + census tests), land on main. Scope stays exactly the original card's: no wire/API/SPEC change, item-iterating scans + clampCardLimit untouched.\n\nACCEPTANCE: countColumn uses COUNT(*) on main; regression test present; git branch core/column-census deleted after landing.","kind":"bug"},"tags":["bug"],"links":[{"type_id":"related","target":"card_d39fd11975ef40cebeb3feafca2f331c","note":"lands the stranded census commit on main","created_by":"claude","created_at":"2026-07-12T01:54:14.678323Z"}],"comments":[{"id":"cm_34173b11628841b9","author":"claude","body":"DONE: e8b5565 cherry-picked onto main clean (628e9f8), merged in a3dec98. countColumn now COUNT(*) via Store.CountCards; census_test.go regression included; suite + -race green. core/column-census deleted locally; no remote copy existed.","created_at":"2026-07-12T03:04:33.771549Z","edited_at":"0001-01-01T00:00:00Z"}],"version":8,"created_at":"2026-07-12T01:54:14.607416Z","updated_at":"2026-07-12T03:04:33.843818Z","created_by":"claude","status_since":"2026-07-12T03:04:33.843818Z"},"type":"card"}
{"data":{"id":"card_45a21e3fc22d431bb19e6d25e8d66bf9","workspace_id":"demo","type_id":"programming-task","schema_version":2,"title":"README: cut to ≤120 lines — lead with the board, three-command start, link out","status":"backlog","fields":{"branch":"docs/refresh-readme","description":"Rewrite README.md to the shared storyline (parent card). Keep: one-sentence hook + board screenshot; the why paragraph; init/serve/open; one ≤15-line card-type example with the one-schema-everywhere point; choose-your-path links; install (binary + go install, Gatekeeper note); license. Drop from the README (they live on the site): Adding Cards to a project, Configuration, Syncing across machines, API and runtime behavior, Extensions, Project layout, Documentation and development, Releases — each replaced by one link line. Normalize the CLI examples: the README currently shows CARDS_URL both with and without the /v1 suffix (README.md:165 vs :221; both work, normalizeBase in internal/cli/client.go), pick the bare form everywhere. Write the CLI snippet against Sprint A's outcome (--if-match latest, --md) so it lands after 069ec1d1 and c0102825.\n\nAcceptance: wc -l README.md ≤ 120; every link resolves (site + repo); the three commands in the quick start work on a fresh checkout as written; CHANGELOG unaffected.","kind":"design"},"links":[{"type_id":"parent","target":"card_65f8ae3848c046b092cdbbb2da68871d","created_by":"claude","created_at":"2026-09-06T18:27:53.546008Z"},{"type_id":"depends-on","target":"card_069ec1d16a804b3286dfccb64d10d0e4","created_by":"claude","created_at":"2026-09-06T18:27:53.690669Z"}],"version":3,"created_at":"2026-09-06T18:27:53.420346Z","updated_at":"2026-09-06T18:27:53.690669Z","created_by":"claude","status_since":"2026-09-06T18:27:53.420346Z"},"type":"card"}
somebox and others added 2 commits October 12, 2026 07:49
The loader only seeded settings.default_user into workspace.users when that
list was empty, so a workspace that declared any other user (say review-bot)
left its own default actor unable to own cards: unattributed writes resolve
to that actor, and claim then 422s unknown_user. The docs this branch
updated already described default_user as unconditionally registered.
Append it whenever it is missing, so introspection lists exactly the users
the ownership check accepts.

Also record the --md dependency (c0102825) on the README cut and the Using
Cards split, which both wait for it but only linked the --if-match card.

Addresses the Copilot review on #30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@somebox
somebox merged commit e7af45b into main Oct 12, 2026
4 checks passed
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.

2 participants