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.
The org admin SPA was org-admin only: every project route used
requireOrgRole, so a plain MEMBER could not reach the projects they held
a project grant on, and an org OWNER/ADMIN could mutate any project
without holding the project's `manage` grant. That contradicts ADR-0004
(spec `Permission.lean`): org role is not a project authorization root,
and the only out-of-role override is platform-admin force-release
(`RequiresAdmin`), not org admin.
Add `requireProjectPermission` (guards.ts): resolve any org member, bind
the project to their org, then check the PermissionGrant authorizer.
`allowOrgAdminOversight=true` lets OWNER/ADMIN through for *read*
oversight only; mutations pinned to `collaborator.manage`
(grant/revoke team-access) pass `allowOrgAdminOversight=false`, so an org
admin still needs the project MANAGE grant to mutate access. The project
detail GET now also returns `actorIsOrgAdmin` and `actorCanManageProject`
so the SPA can render mutation controls only for entitled actors.
Add a member-facing project surface:
- `GET /api/org/:orgSlug/my-projects` + `listMyProjects` resolve the
actor's principals and return the projects with a READ+ grant.
- The SPA routes members (non-admin) to the projects page instead of the
admin overview, renders a member project shell on project routes, shows
a `我的项目` list for members and the full folder explorer for admins.
- The project detail page gates rename/archive/bind/sessions behind org
admin and the grant/revoke UI behind `actorCanManageProject`.
- The denied panel now points members at their authorized projects.
Update admin-members-teams integration test: seed the owner with a MANAGE
grant on the test project so the org-owner flow still passes the new
project-level gate on team-access grant/revoke.
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.
Archiving a team no longer cascade-revokes its active TEAM->PROJECT grants
and memberships. The archived flag alone makes the team principal
unresolvable (permissions/principals.ts refuses archived teams), so the
dead grant/membership rows confer no access. listProjectTeamAccess now
filters archived teams out of the project view instead of relying on a
revokedAt cascade, and the org-admin teams page confirm copy is updated.
archiveTeam drops the revokedGrants count from its return shape.
ADR-0019 / Spec.System.Organization: principal resolution, not grant
mutation, is the access boundary for archived teams.
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).
Add a ⛔ 中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.
- builder.ts: render interrupt action (danger button + confirm) while the
run is live; new `interrupted` flag renders a 已中断 footer instead of
完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
resolves the interrupt button, authorizes agent.cancel, aborts the run.
Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
(MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
"谁能取消"). Default role remains MANAGE (Capability.normalCancel).
Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
Replace text-only PatchableTextStream with a unified StreamingAgentCard that
renders the full agent run lifecycle in a single Feishu interactive card:
- Tool-use panel: collapsible, shows each tool call with status (running/
success/error), input summary, and result/error in code blocks
- Reasoning panel: collapsible, streams thinking text inline then collapses
on completion
- Answer text: rendered via native Feishu markdown component (no post-row
round-trip)
Runner now captures previously-discarded SDK events:
- thinking_delta → reasoning stream
- tool_use id + input on content_block_start / assistant message
- tool_result from user messages (was entirely unhandled)
New files:
- card/trace-store.ts: per-run in-memory tool-use trace with secret redaction
- card/builder.ts: Feishu card JSON builder (tool panel + reasoning + text)
- card/streaming-card.ts: StreamingAgentCard controller (400ms throttled)
client.ts: add sendCard/patchCard raw JSON primitives