feat(server): extract a shared resource-versioning engine - #880
Merged
Conversation
Agents and guardrails each hand-rolled the same append-only config archive — `AgentVersion`'s doc comment literally said "Mirrors `GuardrailVersion`". With orchestrations (#872) and workflows both needing versioning, that is past the rule of three, so the mechanism now lives once in `src/lib/resourceVersions.ts` and each resource supplies only what is genuinely its own: the config projection, the restore direction (`applyConfig`), the wire mapper naming its parent, and the project-scoped lookup. Version tables stay per resource (`agent_versions`, `guardrail_versions`) so the foreign key to the parent is a real one. Only the lib code is shared — there is no polymorphic version table. The engine is two factories, and the split is load-bearing rather than stylistic: `makeVersionStore` is the write side and knows nothing about the parent beyond its row id, so `agents.ts`/`guardrails.ts` can archive a version without importing the module that imports them back for `updateAgent` / `updateGuardrail`. `makeVersionArchive` adds list/get/restore on top. Also extracts the pure release helpers (`bucketForKey`, `assignReleaseVersion`, `parseActiveRelease`) out of `agentReleaseAssignment.ts` into a resource-neutral `releaseAssignment.ts`. Release *semantics* stay per resource — what a release targets differs — so no new behaviour is added there. Agents are behaviour-preserving: the 46 existing agent-version tests pass unchanged. Guardrails are levelled up onto the same surface. `GuardrailVersion` gains `public_id` exposure, `label`, `created_by`, and a `(guardrail_id, version)` unique index; its `document` column becomes `config`, holding `{ document }`. Only the policy document is versioned — name, description and the context binding are metadata, and bumping the version for them would make two version numbers denote the same policy, which is exactly what an evaluation record cites. Two new endpoints follow the agent shape: GET /api/v1/guardrails/{guardrail_id}/versions POST /api/v1/guardrails/{guardrail_id}/versions/{version}/restore Guardrail writes now dedup: re-writing the document a guardrail already holds archives nothing, which is what makes restoring the live policy a no-op instead of an endless version chain. Restore appends rather than rewinding, so an approval item or exception citing an intermediate version still resolves. BREAKING CHANGE: the `GuardrailVersion` response no longer carries a top-level `document` field. The archived policy moved to `config.document`, alongside the new `id`, `label` and `created_by` fields, so guardrail versions share one shape with agent versions. Read `config.document` where you read `document` before. The `guardrail_versions.document` column is renamed to `config` and rewrapped; schema sync does not migrate existing rows. Refs #877 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015p7xnykZSQfhiuq8rky2hW
Deploy Outputs
|
This was referenced Aug 8, 2026
Open
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #877 (layer 1 — the version archive). Layer 2's pure helpers are extracted; layer 2's semantics and layer 3 stay per resource, as the issue specifies.
Scope
Per the sequencing in #877, this PR is step 2 only: extract the generic archive layer and migrate agents + guardrails onto it. #872 (orchestration run pinning) and step 3 (
OrchestrationVersion/WorkflowVersion) are deliberately not in here — they build on this engine, and folding orchestration execution semantics into a refactor would make both harder to review. Step 4 (release semantics per resource) is on-demand by the issue's own wording.What was actually duplicated
Less than the issue implies, which shaped the design.
AgentVersionarchived a projection of the wire mapper (configJSONB minus an exclusion set), deduped no-op edits, carriedlabel+created_by, and exposed list/get/restore/release.GuardrailVersionarchived a single column (document), on every document write with no dedup, with nolabel/created_by, and exposed onlyget.The engine —
packages/server/src/lib/resourceVersions.tsShared: the archive write, change detection, the version-counter protocol, pagination/ordering, restore-appends-not-rewinds, the not-found messages, the snapshot projection mechanics, and the scalar snapshot readers.
Per resource, because it genuinely cannot be shared:
applyConfigmapVersionagent_id/guardrail_id)loadResourceVersion tables stay per resource so the FK to the parent is real. No polymorphic version table, per the issue.
Two factories, and why
makeVersionStoreis the write side and knows nothing about the parent beyond its row id.makeVersionArchiveadds list/get/restore and therefore has to reach the resource's own update path. The split is the absence of a module cycle, not a taste preference:agents.tsneeds to archive a version, andagentVersions.tsimportsagents.tsback forupdateAgent. Each resource's store lives in a*VersionSnapshot.tsmodule that the write path can import freely.Two typing notes worth flagging for review:
ArchivedVersionRowrather than being generic over the concrete row. Sequelize resolveswhereclauses againstAttributes<M>, which cannot be checked whileMis an unresolved type parameter — and the per-resource columns (agentId,guardrailId) are exactly what the engine must not name. Noas any/as unknownwas introduced.versionModelis a thunk, not the class:dbis assigned at boot, after these modules are imported, so capturingdb.AgentVersioneagerly readsundefined.Layer 2 — pure release helpers
agentReleaseAssignment.ts→releaseAssignment.ts, with the doc comment made resource-neutral.bucketForKey/assignReleaseVersion/parseActiveReleasewere already pure; nothing else changed. Release semantics stay inagentVersions.ts, since what a release targets differs per resource.Agents — behaviour-preserving
No API change. The 46 existing agent-version tests pass unchanged, which is the check that matters here.
Guardrails — levelled up to the agent surface
Model:
document→config(holding{ document }), pluslabel,created_by_user_id+ thecreatedByassociation, and a(guardrail_id, version)unique index matchingagent_versions. New endpoints:version_labelis now accepted on guardrail create/update, andcreated_byis threaded from the request user, both mirroring agents.Behaviour changes beyond the shape:
The
GuardrailVersionresponse no longer carries a top-leveldocument. The archived policy is atconfig.document, alongside the newid,labelandcreated_by. Callers readconfig.documentwhere they readdocumentbefore.The DB column is renamed
document→configand rewrapped. The repo has no migration mechanism — schema is managed bysync --alter— sosyncadds an emptyconfigcolumn and existing archived guardrail documents are not carried over. New versions archive correctly from the first write. Flagging explicitly since it is a data consideration, not just an API one; a backfill is a one-statementUPDATE guardrail_versions SET config = jsonb_build_object('document', document)if that history matters for a given deployment.Marked with a
BREAKING CHANGE:footer, solerna version --conventional-commitswill pick it up — note the repo currently uses theangularpreset, where the footer (notfeat!:) is what triggers the major bump.Design questions resolved without asking
Per
.claude/rules/open-questions.md:Verification
All run locally against Postgres 16 + pgvector:
pnpm typecheck(server, app, sdk, cli) — cleanpnpm eslint src— clean, no newas any/as unknownpnpm --filter @soat/server test— 4921 passed, 177 suites. The one failure seen initially wasSOAT_BASE_URLleaking from the session environment intofiles.test.ts; green with it unset.pnpm --filter @soat/postgresdb test— 15 passed, including the schema-drift suite that verifies the new unique index materializes undersync({ alter: true })pnpm run docs-lint,pnpm run test:harness— cleanspectral linton the OpenAPI specs — cleanNew tests:
rest/guardrailVersions.test.ts(21 tests — archive on create, config shape pinned, dedup on metadata-only and no-op writes, list ordering + pagination, restore appends / no-op / metadata-untouched, 400/401/403/404), 3 added torest/mcp.test.tsfor the two new MCP tools, and the 2 changed assertions inrest/guardrails.test.ts.Smoke steps added to
tests/smoke-tests.shcovering version 1 on create, metadata-only edits archiving nothing, a document change archiving v2 with its label, and restore appending v3. Not executed here — the smoke stack needs Docker, which this environment lacks — so CI is the first real run of those.Docs:
modules/guardrails.mddata model + a rewritten Versioning section + list/restore examples;tutorials/gate-a-tool-with-guardrails.mdupdated where it read.documentoff a version response.🤖 Generated with Claude Code
https://claude.ai/code/session_015p7xnykZSQfhiuq8rky2hW
Generated by Claude Code