Replace the single-scalar cost model on AgentRun with an append-only
UsageFact ledger. One AgentRun owns zero or more UsageFact rows; each
records one billable consumption event (model completion, external
capability, or tool proxy) with its own provider/model/tokens/quantity/
cost. AgentRun.costUsd/inputTokens/outputTokens become a derived rollup
cache.
This unblocks external capabilities (PDF->MD bundle, audio/video->text)
that bill in non-token units (pages, seconds) through a different
provider than the main agent loop, without per-capability schema changes
or nested AgentRuns (which would pollute lock/admission/session
semantics).
Contract:
- spec/Spec/System/Agent/Usage.lean pins UsageFact, UsageFactKind,
CostSource and three invariants: append-only; belongs to one run,
never holds a lock; missing cost != zero (ADR-0022).
- ADR-0026 records the decision, the rejected nested-Run alternative,
the rollup cache strategy, and the deferred capability registry /
pricebook / post-hoc correction flows.
Schema:
- UsageFact model with indexes on (runId, occurredAt), (runId, kind),
(provider, model, occurredAt), (capabilityId, occurredAt).
- Migration backfills one synthetic model_completion fact per existing
run with recorded cost/tokens (correlationId = runId marks backfill);
truly unrecorded runs stay runsWithoutCost per ADR-0022.
Write path (trigger finish):
- Write the UsageFact first, then mirror it onto AgentRun as two
separate statements (not one transaction). The fact is the truth so it
goes first; the cache is derived so it goes second. A crash between
them leaves the cache stale but the usage service re-reads facts
directly, so this is recoverable; the reverse order would lose the
truth. Separate statements also avoid an AgentRun row lock held across
the insert's FK ShareLock, which deadlocked concurrent workspace
teardown under the Organization->Project->AgentRun->UsageFact cascade.
Read paths:
- org/usage.ts aggregates from UsageFact, ignoring the AgentRun cache.
- slash /usage buckets by (fact.provider, fact.model); a run with a
main loop + an external call lands in two buckets.
- session detail exposes usageFacts[] + costSource for future per-run
cost-breakdown UI.
Tests:
- usage.test.ts: 6 integration tests pin fact aggregation, missing-cost-
!=-zero, multi-fact-per-run, empty-run, project-level, empty-org.
- trigger.test.ts: existing /usage assertion ($0.0023,
openrouter / mock-model) passes on the fact path.
- feishu-reactions mock prisma gains usageFact.create.
ADR-0022 / Spec.System.Capacity pins layered capacity limits: a platform
ceiling per dimension is unbreakable, and each organization may only set a
lower `organizationLimit`. The effective limit is the minimum of the two
(`LayeredLimit.effective`); dimensions with no org override fall back to
the platform ceiling. No dimension may be unlimited (a ceiling must exist
before an org limit can be set, `LayeredLimit.Valid`).
Add the backend: the pinned 23 CapacityDimension set + labels
(src/capacity/dimensions.ts), platform ceilings sourced from existing
runtime env vars plus `HUB_CEILING_<DIMENSION>` (src/capacity/ceilings.ts),
the OrganizationCapacityPolicy prisma model + migration, the
getCapacityPolicy/setCapacityPolicy service enforcing LayeredLimit.Valid,
and org-admin GET/PUT /api/org/:orgSlug/capacity-policy routes wired into
the org route tree.
Add the admin-web surface: CapacityDimension/CapacityPolicyView api client
types, capacityPolicy/setCapacityPolicy methods, a `容量` nav entry, and a
capacity page that lists every dimension with its platform ceiling (or
`未配置` when unset), an org-limit input (disabled until a ceiling exists),
and a live effective-value column. Saving sends the partial limits map;
the service rejects values above the ceiling or for unconfigured dimensions.
ADR-0023 / Spec.System.PlatformAdministration pins the platform
administrator as a separate identity/session/audit control plane,
intentionally not modeled in alpha (ADR-0025). The legacy
PlatformRoleAssignment / PlatformRole{ADMIN,TEACHER} table had no runtime
reader (no guard, route, or service queried it for an authorization
decision) and ADR-0023 requires it to be replaced before the platform
panel ships.
Drop the model, the PlatformRole enum, the User.platformRoles relation,
and the migration. Stop seeding platformRoles in externalSync principal
ingestion and the integration test helper. Update the doc comments on
OrganizationMembership and PermissionRole to point at the platform-admin
control plane instead of the dropped model.
The 20260709180000_organization_tenant_root backfill only referenced
PlatformRoleAssignment in a one-time INSERT...SELECT; no persistent
object references it, so dropping the table is safe after that migration.
main now ships the canonical org-scoped agent-config implementation
(OrganizationAgentRole/OrganizationAgentSkill + hub/src/agent/configuration.ts
+ hub/src/agent/skillStore.ts per ADR-0018, and envelope-encrypted
OrganizationProviderConnection per ADR-0024). The earlier admin-panel
prototype (OrgModel/OrgRole/simple ProviderConnection baseUrl+authToken,
modelRoutes.ts, agentConfig.ts, migration 20260710120000, admin-web
models/roles pages) is superseded and clashes with main's schema; drop it.
Follow-up still needed: rewire admin-web SPA to the new config APIs
(+layout.svelte nav still lists models/roles, RoleCard.svelte unused).