Skip to content

feat(server): extract a shared resource-versioning engine - #880

Merged
arantespp merged 1 commit into
mainfrom
claude/github-issue-877-0tv399
Aug 8, 2026
Merged

feat(server): extract a shared resource-versioning engine#880
arantespp merged 1 commit into
mainfrom
claude/github-issue-877-0tv399

Conversation

@arantespp

Copy link
Copy Markdown
Member

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. AgentVersion archived a projection of the wire mapper (config JSONB minus an exclusion set), deduped no-op edits, carried label + created_by, and exposed list/get/restore/release. GuardrailVersion archived a single column (document), on every document write with no dedup, with no label/created_by, and exposed only get.

The engine — packages/server/src/lib/resourceVersions.ts

Shared: 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:

Adapter Why
the config projection which slice of the resource is configuration
applyConfig the restore direction: config → that resource's update args
mapVersion the wire field naming its parent (agent_id / guardrail_id)
loadResource project-scoped lookup and its not-found message

Version tables stay per resource so the FK to the parent is real. No polymorphic version table, per the issue.

Two factories, and why

makeVersionStore is the write side and knows nothing about the parent beyond its row id. makeVersionArchive adds 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.ts needs to archive a version, and agentVersions.ts imports agents.ts back for updateAgent. Each resource's store lives in a *VersionSnapshot.ts module that the write path can import freely.

Two typing notes worth flagging for review:

  • The engine deals in a structural ArchivedVersionRow rather than being generic over the concrete row. Sequelize resolves where clauses against Attributes<M>, which cannot be checked while M is an unresolved type parameter — and the per-resource columns (agentId, guardrailId) are exactly what the engine must not name. No as any / as unknown was introduced.
  • versionModel is a thunk, not the class: db is assigned at boot, after these modules are imported, so capturing db.AgentVersion eagerly reads undefined.

Layer 2 — pure release helpers

agentReleaseAssignment.tsreleaseAssignment.ts, with the doc comment made resource-neutral. bucketForKey / assignReleaseVersion / parseActiveRelease were already pure; nothing else changed. Release semantics stay in agentVersions.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: documentconfig (holding { document }), plus label, created_by_user_id + the createdBy association, and a (guardrail_id, version) unique index matching agent_versions. New endpoints:

GET  /api/v1/guardrails/{guardrail_id}/versions
POST /api/v1/guardrails/{guardrail_id}/versions/{version}/restore

version_label is now accepted on guardrail create/update, and created_by is threaded from the request user, both mirroring agents.

Behaviour changes beyond the shape:

  • Dedup. Re-writing the document a guardrail already holds no longer bumps the version. This is what makes restoring the live policy a genuine no-op rather than an endless version chain.
  • Restore appends. Restoring v1 of a guardrail at v2 writes v3, so an approval item or exception citing v2 still resolves. Only the policy rolls back — name, description and the context binding are untouched.

⚠️ Breaking change

The GuardrailVersion response no longer carries a top-level document. The archived policy is at config.document, alongside the new id, label and created_by. Callers read config.document where they read document before.

The DB column is renamed documentconfig and rewrapped. The repo has no migration mechanism — schema is managed by sync --alter — so sync adds an empty config column 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-statement UPDATE guardrail_versions SET config = jsonb_build_object('document', document) if that history matters for a given deployment.

Marked with a BREAKING CHANGE: footer, so lerna version --conventional-commits will pick it up — note the repo currently uses the angular preset, where the footer (not feat!:) is what triggers the major bump.

Design questions resolved without asking

Per .claude/rules/open-questions.md:

Q: Should a guardrail's versioned surface become its whole mutable surface
   (name, description, document, context binding), matching how an agent
   versions everything?
A: No — version only `document` — resolved by long-term; checked: `version` is
   stamped into evaluation records and cited by approval items and exceptions
   (`governingGuardrailVersion`), so bumping it on a rename would make two
   version numbers denote the same policy. Boundary integrity beats surface
   symmetry here.

Q: Should guardrail writes adopt the agent path's no-op dedup?
A: Yes — resolved by pareto; checked: it is required for `restore` of the live
   policy to terminate, and archived versions are only ever appended, so no
   existing audit reference is invalidated by writing fewer of them.

Q: Where does the shared engine live, and how is the agents.ts ↔
   agentVersions.ts module cycle avoided?
A: `src/lib/resourceVersions.ts`, split into a store (write side) and an archive
   (read/restore side) — resolved by long-term; checked: the pre-existing code
   already avoided this cycle via `agentVersionSnapshot.ts`, so the split
   preserves an established boundary rather than inventing one.

Q: Exclusion set or allowlist for the config projection?
A: Exclusion — resolved by pareto (pattern hygiene); checked: it is the existing
   agent behaviour, its failure mode is loud (spurious versions) rather than
   silent, and `agentVersions.test.ts` pins the exact key set.

Verification

All run locally against Postgres 16 + pgvector:

  • pnpm typecheck (server, app, sdk, cli) — clean
  • pnpm eslint src — clean, no new as any / as unknown
  • pnpm --filter @soat/server test4921 passed, 177 suites. The one failure seen initially was SOAT_BASE_URL leaking from the session environment into files.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 under sync({ alter: true })
  • pnpm run docs-lint, pnpm run test:harness — clean
  • spectral lint on the OpenAPI specs — clean

New 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 to rest/mcp.test.ts for the two new MCP tools, and the 2 changed assertions in rest/guardrails.test.ts.

Smoke steps added to tests/smoke-tests.sh covering 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.md data model + a rewritten Versioning section + list/restore examples; tutorials/gate-a-tool-with-guardrails.md updated where it read .document off a version response.

🤖 Generated with Claude Code

https://claude.ai/code/session_015p7xnykZSQfhiuq8rky2hW


Generated by Claude Code

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
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploy Outputs

Package Stack Output Key Output Value
@soat/website SoatWebsite-claude-github-issue-877-0tv399 BucketWebsiteURL http://soatwebsite-claude-github-issue-877-0-staticbucket-u6yrecfd8p9o.s3-website-us-east-1.amazonaws.com

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.

[feature] Generalize the versioning engine across agents, guardrails, orchestrations, and workflows

2 participants