-
Notifications
You must be signed in to change notification settings - Fork 3
plat 184
PLAT-184 — Pulse and the Workflow Builder cannot access the cost ledger at all, including everything PLAT-166/167 added
| Coordination | Value |
|---|---|
| Assigned agent | unassigned |
| Ticket state |
fixed — per-workspace ledger shipped (5552fbe71, main); see "Fix implemented" below for what's covered and what's deliberately still open |
| Last synchronized | 2026-08-23 |
-
Priority: P1 — Pulse's own guidance (
pulse-gate.md) assigns it amodel_cost_fitnessreview lens ("model/tier/reasoning/fallback choices, quality-cost fit, and cost attribution"), and PLAT-090 records the operator's own request ("we should measure pulse time, cost..."). Neither is actually possible today: Pulse cannot read the data it's supposed to reason about. -
Owner: cost ledger storage (
pkg/costledger), server startup (cmd/server/server.go:1551-1560), cost observer attribution (pkg/costobserver). - Related: PLAT-166/PLAT-167 (added the phase/item-level cost breakdown this ticket's data is entirely invisible for — see below), PLAT-090 (the standing "measure Pulse cost" requirement this blocks), PLAT-088 (scope attribution — confirmed unrelated to this ticket, see Non-goals).
While reviewing whether PLAT-166/167's cost-tracking work actually satisfied
its own requirements, a user asked directly: can the Workflow Builder agent
read _system/costs.sqlite? Traced through folder-guard scoping and
confirmed: no. Every workflow step's (and even the broadest
"generic agent"/Pulse-specialist) read scope is bounded by
baseWorkspacePath = hcpo.GetWorkspacePath()
(pkg/orchestrator/agents/workflow/step_based_workflow/controller_agent_factory.go:388,433,439),
which resolves to that workflow's own folder (e.g. Workflow/social-media).
_system/ is a sibling of Workflow/, not a descendant, so it sits outside
every possible grant regardless of role. execute_shell_command enforces
the same boundary at the OS level via Landlock, so there's no route around
it through shell either.
No tool exists to compensate for this. Unlike Pulse's own findings/state
(_system/pulse-platform.sqlite), which is mediated through a family of
dedicated tools (record_pulse_review_focus and siblings,
cmd/server/pulse_worklist.go:2024 →
pkg/orchestrator/agents/workflow/step_based_workflow/pulse_finding_details.go),
grepping the entire codebase for any get_costs/query_costs/similar
tool registration found nothing. The cost ledger has zero agent-facing
surface — not even an indirect one.
Consequence for PLAT-166/167 specifically: those tickets added phase
(reflection) and message-sequence item-level (item:<id>) cost
attribution to pkg/costledger — the exact hierarchy (↳ execute and verify, ↳ ... reflection, ↳ automatic final validation repair N) a
human sees in the Cost Analysis popup. None of that new granularity exists
in the older, file-based cost system
(costs/execution/, costs/phase/, read via
pkg/orchestrator/base_orchestrator_tokens.go,
context_aware_bridge.go) that agents can read — PLAT-167's own work is
entirely invisible to Pulse and the Workflow Builder, not just historically
but by construction, since it only ever wrote to the SQLite ledger.
costDBPath := filepath.Join(fsutil.WorkspaceDocsRoot(), "_system", "costs.sqlite") (server.go:1551) is opened once at server startup and
published process-wide via costledger.SetDefaultLedger (server.go:1557).
Every workflow's cost events land in one shared cost_events table, keyed
by a workflow_id column. This is the only reason the data is
unreachable by any agent — the older file-based system already lives inside
each workflow's own folder and is readable exactly because of that.
A full writer/reader catalog was done against the live ledger
(workspace-docs/_system/costs.sqlite, 23MB, 28,276 rows) to assess whether
moving to one SQLite file per workspace (e.g.
Workflow/<name>/costs/costs.sqlite) is feasible:
Writers — mostly already workspace-scoped:
-
pkg/orchestrator/base_orchestrator_cost.go:48,73(WithAttribution(..., bo.GetWorkspacePath(), ...)) — every workflow-step/background-agent attach point already carries the workspace path. Clean migration target. -
cmd/server/virtual-tools/tool_costs.go:127-238(priced media tools) — already dual-writes: the global ledger row, and a per-workflow JSON file, wheninferWorkflowCostTargetcan resolve aWorkflow/<name>/...segment from the tool's own output path. -
cmd/server/server.go:5745-5751(the/api/querychat/builder path) — genuinely ambiguous by design:req.SelectedFolderis legitimately empty for plain chat or a workflow-builder session before any workflow folder is chosen. This writer needs a real decision (a separate landing place for workspace-less events), not a mechanical fix.
Readers — all but one already workflow-scoped:
-
cmd/server/workflow_review_data.go:127,198,cmd/server/scheduler.go:2378,pkg/orchestrator/agents/workflow/step_based_workflow/pulse_agent_metrics.go:112— all single-workflow already, or trivially made so (the metrics one hasworkspacePathin scope at the call site, just doesn't pass it down yet). -
cmd/server/cost_routes.go:41(GET /api/cost/summary, backsfrontend/src/components/CostDashboard.tsx) is the one genuinely cross-workflow reader — butCostDashboardis not imported or rendered anywhere else in the frontend (confirmed:grep -rl "CostDashboard" frontend/src/returns only its own file). This looks like dead/unmounted UI, not a live constraint on the migration.
The empty per-workflow costs.sqlite red herring, resolved:
Workflow/social-media/costs/costs.sqlite (0 bytes) is the only file of
its shape anywhere under any workflow folder, and nothing in the codebase
references that path pattern outside test tempdirs. It is not a half-built
migration attempt — no TODO, no scaffold, no evidence of intent. It's a
stray artifact, most likely a one-off os.Create-then-never-written side
effect from something unrelated; safe to delete whenever convenient, not
meaningful to this ticket's design.
The apparent "54% of the ledger is unattributed" finding, resolved as a
non-issue: an initial pass found 15,299 of 28,276 rows (54%) have
workflow_id=''. Investigated and confirmed not a live defect: 15,235
of those rows have scope='unknown' and every single one has
event_id LIKE 'legacy-%' — they are a one-time historical import of the
pre-SQLite JSONL cost log
(server.go:1560, costledger.MigrateLegacyJSONL(legacyCostPath), runs
on every startup but is idempotent via
idempotency_key = "legacy-jsonl:" + sha256(line),
pkg/costledger/sqlite.go:443,461-477). Confirmed via direct query: every
one of these rows is timestamped 2026-05-03 through 2026-07-12, with
zero rows since — over a month before this investigation. Already
independently documented: commit e74710d0d's message states this
verbatim, and PLAT-090 (plat-090.md:111-116) cites the identical 15,235
figure and already excludes it from live trend analysis. Once this dead
bucket is excluded, live cost data is essentially fully workflow-attributed
already — this finding, once resolved, makes the migration case stronger,
not weaker.
Not mutually exclusive, and meaningfully different in cost/risk:
-
Migrate to one SQLite ledger per workspace. Matches the existing
file-based system's pattern, gives every agent normal folder-guard read
access with zero new tool surface, and per the scoping above is mostly
mechanical for the parts that matter — but is a real data migration
(splitting/rewriting existing rows by
workflow_id, deciding where workspace-less events land, handling the schema init path per file rather than once). -
Build a dedicated cost-query tool, mirroring how
_system/pulse-platform.sqlitealready works (server-side Go code reads the global file, exposes a properly workflow-scoped view through a virtual tool). Lower risk, no data migration, preserves the current single-file architecture and whatever benefit it has for admin-level queries — but doesn't fix the underlying architectural inconsistency (this ledger would remain the only cost-relevant system that isn't naturally per-workspace), and adds a second access pattern (tool-mediated vs. direct-file) that the older cost system doesn't need.
- Not re-litigating PLAT-088 — confirmed unrelated (a different, already- fixed live-write attribution bug, not this ticket's read-access gap).
- Not deciding between the two fix directions above — that's the next step, not resolved here.
- Not fixing the workspace-less chat/builder writer's ambiguity
(
server.go:5745) — flagged as a real open question either fix direction will need to answer, not resolved in this pass.
- A Pulse
model_cost_fitnessreview pass, or a Workflow Builder chat turn, can retrieve its own workflow's cost data — including the phase/item-level breakdown PLAT-166/167 added — without going outside its normal read/tool scope. - Cross-workflow cost reporting (if kept) continues to work, or is
confirmed genuinely unused (per the dead-
CostDashboardfinding above) and intentionally dropped. - The workspace-less writer path (
server.go:5745) has an explicit, documented landing place under whichever design is chosen — not silently dropped or silently misattributed to a workflow that didn't produce it.
Direction 1 was chosen (per-workspace SQLite ledger), but scoped down at the
user's explicit direction: dual-write, no backfill. The existing 28,276
rows in _system/costs.sqlite are untouched and that ledger stays
authoritative for the human-facing Cost Analysis UI. Going forward only,
every cost event attributable to a real Workflow/<name> workspace is
written to both the global ledger (unchanged) and a new
Workflow/<name>/costs/costs.sqlite (pkg/costledger/workspace_ledger.go),
via a new best-effort appendToWorkspaceLedger hook in
costobserver.Observer.append (pkg/costobserver/observer.go) that never
blocks or fails the primary write.
A new read-only tool, query_workflow_costs
(cmd/server/virtual-tools/workflow_costs_tools.go), mirrors
query_workflow_db's shape (raw read-only SQL against one table, action
optional, same schema-hint-on-failure behavior) but resolves to this
per-workspace file instead of db/db.sqlite. It is registered as
capability-derived — the same "cannot be removed by a custom tool allowlist"
treatment query_workflow_db already gets — at every path a workflow
step, a Pulse background reviewer, or the interactive Workflow Builder gets
its tools from
(pkg/orchestrator/agents/workflow/step_based_workflow/controller_agent_factory.go's
two prepareCustomTools branches, interactive_workshop_manager.go's
prepareReadOnlyBackgroundAgentTools and GetToolsForWorkshopMode system
list, and cmd/server/tool_setup.go's workflow-mode tool merge).
Guidance updated to point at the new tool instead of describing the ledger
as unreachable: pulse-gate.md's model_cost_fitness focus key,
file-layout.md's file table, and the Workflow Builder's own "answer user
questions" instruction list in interactive_workshop_manager.go.
Acceptance tests, against the three listed above:
-
Met, and proven through the real tool-call path, not just storage:
cmd/server/workflow_costs_tool_e2e_test.go'sTestQueryWorkflowCostsToolReadsOwnPhaseBreakdownAndIsIsolatedFromAnotherWorkflowruns the actual MCP-bridge-shaped executor against a real workspace-API HTTP server and two real per-workspace SQLite files, and asserts a session scoped toWorkflow/social-mediagets its ownphase="item:draft-message"row and never sees Upwork's9.99/item:apply-to-jobrow (including when adb_pathargument naming Upwork's file is smuggled into the call — the tool has no path parameter at all, so it's silently ignored).pkg/costledger/workspace_ledger_test.gocovers the same isolation property one layer down, at the ledger/storage level. -
Met. The global ledger and
cost_routes.go's cross-workflow reader are unchanged; this fix only adds a second write, it removes nothing. -
Still open, deliberately. The workspace-less chat/builder writer path
(
server.go:5745, formerly cited at that line number, may have moved) still has no per-workspace ledger to land in —WorkspaceLedgerPathreturns""for any non-Workflow/<name>path, so those events continue to reach only the global ledger, exactly as before this fix. This was explicitly out of scope for this pass (the user's instruction was "new is fine" for workflow-scoped events specifically, not a mandate to also resolve pre-existing chat/builder attribution ambiguity) — left as a real open question for a future ticket if it turns out to matter.
GOWORK=off go build ./... clean. go test across
cmd/server/..., cmd/server/virtual-tools/..., pkg/costledger/...,
pkg/costobserver/..., and
pkg/orchestrator/agents/workflow/step_based_workflow/... shows zero new
failures against this session's already-established pre-existing baseline
(TestWorkshopResolveLLMConfigExpandsCodingAgentMode,
TestStandalonePulseReviewCommandsUsePersistedReviewerPipeline,
TestArtifactDriftAuditsTheSchedule,
TestRunInBackgroundPassesBuilderSkillSnapshotToBothAgentKinds,
TestWorkshopPromptShellExamplesUseAbsolutePaths). The workshop tool-set
invariant tests (TestToolSetInvariants,
TestEveryRegisteredWorkshopToolIsAllowedInSomeMode,
TestWorkshopSurfaceCoversWhatTheBuilderPromptsPromise, and siblings) pass,
confirming query_workflow_costs is both registered and reachable in every
mode that advertises it.
Auto-synced from docs/ on main. Edit there, not here.