mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 23:07:35 +00:00
ffc11387e1
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)
Store, REST and the read-time judgment for per-criterion verdicts:
- item_verifications (SQLite 150, PG 120), append-only, with the NUL
census classified and trigger file 151 generated.
- internal/verification: criterion hash (whitespace-collapsed), the
removed / changed / superseded / current state, and the advisory
possibly_stale hint from a linked PR merged after the verdict.
- POST/GET /items/{ref}/verifications: atomic batch, seq-guarded so a
criterion hash is stored against the body it came from, via resolved
in-workspace and visibility-checked, activity "verified", item_updated
SSE.
- github_pr carries merge_sha / merged_at, fetched by pad github link
and reconcile.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): router tests, body-order sort, compress census, CLI merge fields (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): close mutant holes M5 (re-spacing) and M8 (merged before the verdict); placeholder folds into missing (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* docs(api): the verifications endpoint (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r1 findings: the retry re-checks access; a moved item is told from a gone via (TASK-3617)
- F1: when the item moves between the criteria read and the write, the
retry re-runs visibility and edit permission on the fresh item.
- F2: a zero-row insert re-checks the target in the transaction, so a
Postgres seq race retries (or 404s) instead of answering unknown_via.
- F3: verificationBeforeInsertHook test seam; tests for one move, an
item that keeps moving (409), and a grant revoked mid-retry (404).
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r2 L1, the retry re-resolves via; gate findings (TASK-3617)
- The retry re-runs the via resolution and its visibility check, the same
re-check F1 added for the item; TestTASK3617_RetryRechecksVia.
- The state-history test asserted by row position, and one batch's rows
share a created_at, so their order is by random id: assert by criterion.
- nulscan.go's protected-column count (146: 27 JSON, 119 text).
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(store): the scan-cost pin follows the list, 146 columns (TASK-3617)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* chore(verify): drop the comment resolveVia's doc comment superseded (TASK-3617 r3)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(verify): r4, verifications travel with a workspace export/import (TASK-3617)
- F1: VerificationExport, exported for the bundle's live items and
imported with the item and via ids remapped, every other value
verbatim. Each bundle row is checked before its insert (a refused
statement on Postgres loses the whole import); a row that fails, or
whose item did not import, is dropped and counted. Tests: a SQLite round
trip, the migrate-to-pg path (SQLite export into a Postgres import with
the slug preserved), and a hostile bundle on both backends.
- F2: docs/api.md says a criterion repeated in one request is
validation_error, as the code answers.
- Q2: gh's mergedAt zero time is read as not merged.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* fix(store): item_verifications is a migrated table (TASK-3617 r4 gate)
The export now carries verifications, so the three lists that name what
`pad db migrate-to-pg` copies must name it too: MigratedTables (the NUL
preflight scans it), its export-section test map, and
migratedRefusalTables (the post-migration write refusal covers it).
Swept by item_reminders across cmd/, internal/ and docs/: those three
are the population.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* chore(store): renumber TASK-3617 migrations after #2230 (sqlite 151/152, pg 121)
#2230 took sqlite 150 and pg 120 for decision_jobs.not_before. The
verification table is now sqlite 151 / pg 121, and its NUL trigger file
is 152_nul_invariant_triggers_post151 (regenerated, so the trigger names
carry pad_nul152_). References swept by the old file names and the bare
numbers in this branch's diff.
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
* test(verify): an id that is both a criterion and a placeholder is refused (TASK-3617, lead spot mutant)
Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
274 lines
13 KiB
Go
274 lines
13 KiB
Go
package models
|
|
|
|
// WorkspaceExport is the complete portable representation of a workspace.
|
|
type WorkspaceExport struct {
|
|
Version int `json:"version"` // Export format version (1)
|
|
ExportedAt string `json:"exported_at"`
|
|
Workspace WorkspaceExportMeta `json:"workspace"`
|
|
Collections []CollectionExport `json:"collections"`
|
|
Items []ItemExport `json:"items"`
|
|
Comments []CommentExport `json:"comments,omitempty"`
|
|
ItemLinks []ItemLinkExport `json:"item_links,omitempty"`
|
|
ItemVersions []ItemVersionExport `json:"item_versions,omitempty"`
|
|
// Reminders round-trip with the workspace (IDEA-2641). They are
|
|
// item-scoped workspace CONTENT, like links and versions, not per-user
|
|
// state like stars and watches — which is the line this list has always
|
|
// drawn, and it puts reminders on the exported side of it. Without them a
|
|
// backup/restore or a SQLite→Postgres migration silently loses every
|
|
// pending reminder, and "silently" is the part that matters: nothing in
|
|
// the destination would show that anything was dropped.
|
|
Reminders []ReminderExport `json:"reminders,omitempty"`
|
|
// Verifications round-trip too (TASK-3617 r4). They are user-written
|
|
// evidence (a verdict, its evidence, the revisions it was about) that
|
|
// nothing can rebuild, unlike typed decisions, which a provider
|
|
// regenerates. Without them a SQLite->Postgres migration drops every one.
|
|
// Additive and omitempty, like reminders, so the format version holds.
|
|
Verifications []VerificationExport `json:"verifications,omitempty"`
|
|
}
|
|
|
|
// VerificationExport is one acceptance-criterion verdict in a workspace
|
|
// bundle. Everything is carried VERBATIM, including the criterion hash, the
|
|
// spec and code revisions and created_at, so a verdict reads the same state
|
|
// after the move as before it: freshness is judged against the criterion's
|
|
// text, which moves with the item. The verifier's user id does not travel
|
|
// (user ids are per instance); created_by and agent_name do.
|
|
type VerificationExport struct {
|
|
ItemID string `json:"item_id"`
|
|
CriterionID string `json:"criterion"`
|
|
CriterionHash string `json:"criterion_hash"`
|
|
SpecSeq int64 `json:"spec_seq"`
|
|
CodeRevision string `json:"code_revision,omitempty"`
|
|
Verdict string `json:"verdict"`
|
|
Evidence string `json:"evidence,omitempty"`
|
|
EvidenceURL string `json:"evidence_url,omitempty"`
|
|
ViaItemID string `json:"via_item_id,omitempty"`
|
|
RunID string `json:"run_id"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
AgentName string `json:"agent_name,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ReminderExport is one item reminder in a workspace bundle.
|
|
//
|
|
// The LIFECYCLE MARKS ARE CARRIED, not reset. A fired-and-unacknowledged
|
|
// reminder is still owed to whoever armed it, so it arrives pending on the
|
|
// destination; an armed one whose instant has passed fires once on the first
|
|
// tick there, which is the same thing that would have happened had the
|
|
// workspace never moved. Re-arming everything on import would be inventing a
|
|
// new schedule the user did not set.
|
|
type ReminderExport struct {
|
|
ItemID string `json:"item_id"`
|
|
RemindAt string `json:"remind_at"`
|
|
FiredAt string `json:"fired_at,omitempty"`
|
|
AckedAt string `json:"acked_at,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// AttachmentManifestEntry describes one attachment blob in the
|
|
// tar-bundle export's attachments/manifest.json. The bundle layout is:
|
|
//
|
|
// pad-export.json # the WorkspaceExport above
|
|
// attachments/manifest.json # uuid → AttachmentManifestEntry
|
|
// attachments/<uuid>.<ext> # the actual blob bytes
|
|
//
|
|
// Thumbnails are NOT bundled — they're re-derived on import via the
|
|
// existing thumbnail pipeline. ParentID and Variant therefore stay
|
|
// nil/empty for every entry shipped in a bundle, but the fields are
|
|
// kept here for forward compatibility (e.g. if a future format
|
|
// version starts shipping pre-derived variants).
|
|
type AttachmentManifestEntry struct {
|
|
ID string `json:"id"` // attachment UUID (the original)
|
|
Filename string `json:"filename"` // user-facing filename
|
|
// FilenameSource is the exporter's provenance for Filename (BUG-2819).
|
|
// Omitted by bundles that predate it. On import it is the BUNDLE's
|
|
// claim, not an observation by the importing server.
|
|
FilenameSource string `json:"filename_source,omitempty"`
|
|
MIME string `json:"mime"` // canonical MIME from upload time
|
|
SizeBytes int64 `json:"size_bytes"` // bytes on disk (matches the blob)
|
|
ContentHash string `json:"content_hash"` // sha256 hex, the dedupe key
|
|
Width *int `json:"width,omitempty"`
|
|
Height *int `json:"height,omitempty"`
|
|
ItemID string `json:"item_id,omitempty"` // exporter's item UUID; remapped on import
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
Variant string `json:"variant,omitempty"`
|
|
UploadedBy string `json:"uploaded_by"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// AttachmentManifest is the top-level shape of attachments/manifest.json
|
|
// inside an export bundle. Wraps a list of entries plus a small
|
|
// "schema" version so the import path can validate / migrate.
|
|
type AttachmentManifest struct {
|
|
Version int `json:"version"` // manifest version, 1
|
|
Entries []AttachmentManifestEntry `json:"entries"`
|
|
}
|
|
|
|
// WorkspaceExportMeta holds workspace metadata for export.
|
|
type WorkspaceExportMeta struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description string `json:"description"`
|
|
Settings string `json:"settings"`
|
|
}
|
|
|
|
// CollectionExport holds a collection's data for export.
|
|
type CollectionExport struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Icon string `json:"icon"`
|
|
Description string `json:"description"`
|
|
Schema string `json:"schema"`
|
|
Settings string `json:"settings"`
|
|
// Traits carries the collection's kernel-trait declarations (SPEC-5).
|
|
// omitempty so an archive from a deployment where nothing declares traits
|
|
// omits the key entirely rather than carrying a noise "{}" on every
|
|
// collection, and so an import of an older archive is unambiguous (absent,
|
|
// not empty).
|
|
//
|
|
// This does NOT make a pre-TASK-2657 archive round-trip byte-identically:
|
|
// import defaults a missing value to "{}" in the column, and any collection
|
|
// the migration then backfills re-exports WITH declarations. Round-tripping
|
|
// an old archive through a current deployment is expected to gain traits —
|
|
// that is the backfill working, not export drift.
|
|
Traits string `json:"traits,omitempty"`
|
|
Prefix string `json:"prefix"`
|
|
// Source is how the collection was created ('web' | 'cli' | 'mcp'),
|
|
// BUG-3447. omitempty: an older bundle has none, and imports it as ''
|
|
// (provenance unknown). A value outside that set imports as '' too.
|
|
Source string `json:"source,omitempty"`
|
|
SortOrder int `json:"sort_order"`
|
|
IsDefault bool `json:"is_default"`
|
|
IsSystem bool `json:"is_system"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
// DeletedAt carries a collection's soft-delete mark, empty for a live
|
|
// collection (BUG-2884).
|
|
//
|
|
// The bundle used to skip soft-deleted collections while exporting every
|
|
// live ITEM, and DeleteCollection soft-deletes only the collection row —
|
|
// its items keep deleted_at IS NULL and stay reachable by ref, by id, and
|
|
// through search, because no item-bearing read joins collection liveness.
|
|
// So the two sections were filtered by different rules and the bundle
|
|
// named a collection it did not carry; ImportWorkspace then dropped those
|
|
// items on its orphan gate, silently. Since pad db migrate is
|
|
// ExportWorkspace piped into ImportWorkspace, that was live data lost on a
|
|
// SQLite→Postgres migration, not just a lossy backup.
|
|
//
|
|
// Filtering the items to live collections instead would have made the
|
|
// migration DELETE reachable rows, so the bundle carries the archived
|
|
// collection and the importer reproduces the archive.
|
|
//
|
|
// omitempty for the same reason as Traits: an archive from a workspace
|
|
// with nothing deleted omits the key rather than carrying a noise "" on
|
|
// every collection. Absent decodes to "" and MUST mean live — that is what
|
|
// keeps every archive written before this field importable.
|
|
DeletedAt string `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
// ItemExport holds an item's data for export.
|
|
type ItemExport struct {
|
|
ID string `json:"id"`
|
|
CollectionID string `json:"collection_id"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
Content string `json:"content"`
|
|
// ContentState records that CONTENT above was behind the item's live
|
|
// collaborative document when the bundle was written (BUG-3032 / BUG-3000).
|
|
// Same values as models.Item.ContentState, named the same, and empty for
|
|
// every item whose row is current — so a bundle from a workspace with
|
|
// nothing pending is byte-identical to one produced before this field
|
|
// existed.
|
|
//
|
|
// Additive with NO WorkspaceExport.Version bump, deliberately: no bundle
|
|
// decode path sets DisallowUnknownFields, so an older binary ignores this
|
|
// key, while ImportWorkspace compares `data.Version != 1` with EXACT
|
|
// equality — a bump would reject bundles in BOTH directions for an added
|
|
// optional key.
|
|
//
|
|
// Why the bundle needs its own marker rather than inheriting one: this is
|
|
// not a late read. WorkspaceExport carries no op-log, and ImportWorkspace
|
|
// writes Content back as the destination's canonical content, so on
|
|
// `pad db migrate-to-pg` — export piped into import across two databases,
|
|
// after which the source is abandoned — an unflushed body becomes the only
|
|
// copy. The marker is what lets that door refuse; it is not a repair.
|
|
ContentState string `json:"content_state,omitempty"`
|
|
// CollabSetAside carries the edits a collab schema-version rebuild set
|
|
// aside on this item (BUG-3244), which no body holds. Unlike the op-log,
|
|
// which a bundle never carries, these travel: they can only be recovered
|
|
// by a decoder, not by a tab, so a copy of the rows is the whole of what
|
|
// can be kept. Additive and omitempty, on the same no-version-bump grounds
|
|
// as ContentState.
|
|
CollabSetAside []YjsSetAsideExport `json:"collab_set_aside,omitempty"`
|
|
// BuiltinOrigin is the built-in convention or playbook this item was made
|
|
// from (TASK-3462), so a restored workspace can still be offered updates
|
|
// to Pad's text. Additive and omitempty on the same grounds as
|
|
// CollabSetAside; a content hash means the same thing on any instance.
|
|
BuiltinOrigin *BuiltinOrigin `json:"builtin_origin,omitempty"`
|
|
Fields string `json:"fields"`
|
|
Tags string `json:"tags"`
|
|
Pinned bool `json:"pinned"`
|
|
SortOrder int `json:"sort_order"`
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
CreatedBy string `json:"created_by"`
|
|
LastModifiedBy string `json:"last_modified_by"`
|
|
Source string `json:"source"`
|
|
ItemNumber int `json:"item_number"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// YjsSetAsideExport is one set-aside edit row in a bundle (BUG-3244). The item
|
|
// it belongs to is the enclosing ItemExport; update_data is base64 in JSON.
|
|
type YjsSetAsideExport struct {
|
|
OpLogID int64 `json:"op_log_id"`
|
|
SchemaVersion string `json:"schema_version"`
|
|
CreatedAt string `json:"created_at"`
|
|
SetAsideAt string `json:"set_aside_at"`
|
|
UpdateData []byte `json:"update_data"`
|
|
}
|
|
|
|
// CommentExport holds a comment's data for export.
|
|
type CommentExport struct {
|
|
ID string `json:"id"`
|
|
ItemID string `json:"item_id"`
|
|
Author string `json:"author"`
|
|
Body string `json:"body"`
|
|
CreatedBy string `json:"created_by"`
|
|
Source string `json:"source"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// ItemLinkExport holds an item link's data for export.
|
|
type ItemLinkExport struct {
|
|
ID string `json:"id"`
|
|
SourceID string `json:"source_id"`
|
|
TargetID string `json:"target_id"`
|
|
LinkType string `json:"link_type"`
|
|
CreatedBy string `json:"created_by"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ItemVersionExport holds an item version's data for export.
|
|
type ItemVersionExport struct {
|
|
ID string `json:"id"`
|
|
ItemID string `json:"item_id"`
|
|
Content string `json:"content"`
|
|
ChangeSummary string `json:"change_summary"`
|
|
CreatedBy string `json:"created_by"`
|
|
Source string `json:"source"`
|
|
IsDiff bool `json:"is_diff"`
|
|
CreatedAt string `json:"created_at"`
|
|
// PLAN-2348 U2. The line counts and the create marker travel; user_id does
|
|
// not, because an id from another instance names nobody on this one.
|
|
// Additive and omitempty: a bundle from before them imports as unknown.
|
|
LinesAdded *int `json:"lines_added,omitempty"`
|
|
LinesRemoved *int `json:"lines_removed,omitempty"`
|
|
IsCreate bool `json:"is_create,omitempty"`
|
|
// BUG-3820: the hex sha256 of the body the row stands for. Travels so an
|
|
// imported chain stays verifiable; a bundle without it imports as NULL.
|
|
BodySHA256 string `json:"body_sha256,omitempty"`
|
|
}
|