Repository navigation
Assay 0.14.0
Assay 0.14.0 removes the immutability ceremony from the evidence loop, gives
every Source one home reachable from any workspace, prunes Source adoption to a
traceability record, and teaches the CLI to explain its own semantics. The
workspace envelope is 0.14.0+s4+l8: it breaks record formats on purpose, and
assay update migrates a 0.13.0 workspace in place; there are no compatibility
shims behind it.
This note covers the full change set since v0.13.0.
Breaking
- The workspace envelope is now
0.14.0+s4+l8. A0.13.0+s4+l8workspace is
refused by every command exceptassay update, which migrates it and says what
it rewrote. Anything older still requires the external cutover tool. The refusal
for a migratable workspace now namesassay updateand carries an
assay-update:<observed>-><required>locator instead of the cutover locator. source add --mode living|frozenandsource add --capture checkout|archive
are gone, along with theliving,frozen, andarchiveconcepts. A source's
shape follows the material: a Git repository or URL is checkout-backed, and
anything else is copied once intocontent/. Scripts passing either flag will
fail on an unknown option.source.yamlreplacesmodeanddefault_capture_modewith a single
content_modeofcheckoutorcopy. Both retired fields are rejected on read.- Source observations are now cheap append records:
{observation_id, observed_on, lineage_id, source_path, previous_observation, kind, change_class, note, advisories}plusvcsfor a Git source andcapturefor an observation that
preserved bytes. Thefingerprint,manifest,capture_mode,materials_path,
checkout_path, andcapture_pathfields are gone, and a record still carrying
them is rejected on read. No default path computes asha256-treeany more. - Per-observation manifests under
sources/<alias>/manifests/are no longer
written or read. A capture keeps its integrity manifest beside its bytes, at
captures/<id>/manifest.json. source syncandsource switchapply only to checkout-backed sources. Calling
either on copied content is a teaching error that namessource importand
source captureinstead.syncandswitchno longer refuse a dirty checkout, an unrecorded local
commit, or checkout bytes that differ from the last observation. They proceed and
record anobserved with local modificationsadvisory on the observation. Refresh
is non-destructive:git fetchplusmerge --ff-only, neverreset --hardor
clean -fd. An upstream that cannot fast-forward is recorded and left alone.
Byte-loss protection is delegated to Git, which already declines to overwrite
uncommitted work.SourceObservationResolutionreturnscontentMode,contentPath, and
capturePathin place ofmanifestFile,materialsPathas observation state,
andcheckoutPath.SourceStatusEntryreturnscontentMode,latestAdvisories, andcapturesin
place ofmodeandcaptureMode.assay statuscounts sources ascheckouts
andcopiesrather thanlivingandfrozen.assay source addno longer prints aManifest:line; it printsCheckout:or
Content:depending on the content mode.- The
frozensource alias is no longer reserved, since there is nosources/frozen
namespace to protect. - The Source adoption command surface is
take,list,show,remove. The
register,update,status,inspect,evidence add,verify,decide,
history, androllback recordsubcommands are gone, along with the whole
inspection, evidence, decision, and rollback workflow behind them: its storage,
its schemas, theSOURCE_ADOPTION_POLICY_BLOCKED,SOURCE_ADOPTION_STALE, and
SOURCE_ADOPTION_BUSYerror codes, and its tests. A single-user workbench
records decisions inanalysis close --exitand in the adoption's note; the
adoption record's job is traceability — where did this material land — not an
approval pipeline. - A Source adoption is now one mapping: source alias and path, target system and
path, an optional note, an optional tier-1 identity pin, andmode. An intent
that moved several paths is several records. Theassay.source-adoption/v1
schema replaces theassay.source-adoption-definition/v1,
-state/v1,-inspection/v1,-evidence/v1, and-decision/v1family, and
each record is a single file at.assay/source-adoptions/<id>.jsonrather than
a per-adoption directory.--mode adapt|copystays, as descriptive metadata for
upstream-drift review. - Per-adoption
.lockfiles are gone. One record is one atomic file write, so
there is nothing to serialize; adoption writes still pass through the workspace
mutation gate that fences a conversion. assay statusreports Source adoptions asadoptions,systems, andpinned
in place oftargets,acceptedTargets, anddraftTargets.FrameworkStatus
and--jsonoutput change with it.assay checkreports Source adoption structure only: whether each record parses
and validates, whether its filename still matches its id, and whether the system
it names is still registered.
Added
- Cross-workspace Source references.
assay source link <target-workspace> <target-source> [--alias <local-alias>]writes a thin pointer at
sources/<local-alias>/source.ref.yaml— schemaassay.source-reference/v1,
fieldsworkspaceandsource, nothing else — so a Source that already has a
home is reached instead of cloned again.assay source home <alias>says where
an alias actually lives, for owned and referenced Sources alike, and
assay source unlink <alias>forgets the local name. See
docs/source-reference.md. - Read operations on a referenced alias always show the relation.
source status
appendsref -> ../shared-research#qiskitto the row;source log,
source diff, andsource syncprintRelation:andHome:lines. A
reference is never displayed as if the workspace owned the material. - Write operations through a reference —
sync,capture,import,switch—
land in the home. They print the home's path before starting the work, run
under the home workspace's mutation coordination rather than the consumer's,
and add no confirmation gate.--jsoncallers get the same fact from the
result'sreferenceblock.assay statuscountsreferencesand
broken referencesalongsidecheckoutsandcopies. - Deletion stays asymmetric.
unlinkremoves the pointer file and the directory
it was alone in, never recursively and never through to the home; a shell that
somehow holds content keeps it. Aimingunlinkat a Source this workspace owns
is a teaching error, not a delete. Removing an owned Source is only possible in
its home workspace. linkresolves a chain of references at creation time and records the workspace
that actually ownssource.yaml, so runtime resolution is exactly one hop.
Linking a target that is already linked is a notice rather than a failure;
passing--aliasexplicitly creates a second local name for the same home and
reports the existing one.- A reference whose home cannot be reached fails locally.
source statuslists it
asbrokenwith the recorded target and the reason, commands on that alias fail
with an error naming the repair,assay checkreports it as a structure finding
without scanning for the home or repairing it, and every other object in the
workspace keeps working. A reference shell holding its owncheckout/,
content/,observations/, orcaptures/is refused the same way, because a
pointer plus material is two answers to where the Source lives. - A rebuildable clone registry at
~/.assay/clone-registry.json
(ASSAY_CLONE_REGISTRYoverrides the location): entries of normalized origin
URI, home workspace root, alias, andlast_seen.source add,source link,
andsource syncwrite to it best-effort — a write that fails never affects the
command. It buys three hints and no decisions:source addof an origin that
already has a home prints an advisory naming that home and suggesting
source link, then proceeds;source link <target-source>with the workspace
omitted links directly when exactly one home verifies and lists the exact paths
and stops when several do; a broken-reference error appends the registry's
current location when one verifies. Every read re-verifies the workspace and
alias and drops stale entries, it stores absolute machine-local paths while ref
shells prefer relative ones, and it records homes only — never a consumer's
pointer. Deleting the file loses those three hints and no facts. assay source capture <alias> [--note <text>]preserves a source's current
bytes atcaptures/<id>/sourcewith asha256-tree-v1integrity manifest. It
works for either content mode and is the only routine command that hashes a tree.assay source import <alias> <dir-or-archive> [--note <text>]replaces a copied
source's content. The bytes it is about to replace are captured first, so an
import adds a record rather than destroying one.- Evidence tiers are named in the semantics registry and the bundled skill: tier 0
is the default record (alias, date, commit when there is one), tier 1 is identity
(commit and origin, or a tree hash computed on demand for copied content), tier 2
is an explicit capture.analysis close --exit adopt|rejectsuggests recording a
tier-1 pin; it never requires one. assay source adoption remove <adoption>forgets a mapping. It deletes the
record and nothing else; the material in the target system is the target
project's and was never Assay's to write.assay source adoption takegained--note <text>for the rationale worth
re-reading, and records a tier-1 identity pin without being asked: the commit
and origin for a checkout-backed source, asha256-tree-v1content hash for
copied material. A target path that escapes the registered system through a
symbolic link is refused, including one that does not exist yet.assay updategained a one-shot record migration from the previous version. It
runs before the template analysis, reports every record it rewrote, and stamps the
new version last so a failed step leaves the workspace re-runnable rather than
stranded.assay update --dry-runon an unmigrated workspace lists what the
migration would do without touching anything.
Changed
- Source file listings are computed when something needs them rather than stored
with every observation. A capture answers from its manifest; anything else is
hashed at the moment an adoption or a decision asks for a content pin. source diffreads a checkout-backed source's changes straight from Git
(diff --name-statusbetween the recorded commits) and compares stored capture
manifests for copied content.assay checkverifies that each source has readable content and that every
capture still has both its bytes and its manifest, instead of requiring a
fingerprint and a per-observation manifest.assay source logshows each entry's kind, note, and advisories.- The
Upstreamblock inassay statusoffersassay source sync <alias>whenever
the remote actually moved, including for a checkout that also holds local work.
It used to withhold the command there becausesyncwould have refused; now
syncrecords the local modification and proceeds, so the command is the one
action worth taking. The summary for local work says the next sync will record
it instead of telling the reader to preserve or discard it first. - Ordinary
assay updatenow uses recoverable compare-and-swap writes for managed
files and the Assay AGENTS block, refuses occupied exact.newsidecars before
mutation, reconciles stale managed receipts after crash recovery, and no longer
recreates deleted Project guide files. assay task listandassay task validatenow answer separate questions, and
the split is what makeslistusable on a large Task tree.listis discovery
plus storage health: it reads each Task's own envelope once, without taking a
Task lock and without reading any other record, so the findings it still
reports are the ones it cannot help noticing — an unparseabletask.json, an
id that disagrees with its directory, and one id filed in more than one place.
validateowns integrity, which means the lineage graph: proving that every
relation target exists and that nocontributes_to/continues/
supersedeschain forms a cycle requires reading every reachable record, and
it is now the only command that pays for it. A dangling relation target is
reported byvalidateand nowhere else, so a cleanlistis not a claim about
the graph. On a 300-Task workspace with a web of relations,assay task list
went from over two minutes to about 1.5s.assay checkreads each Task's envelope and its own contract files without
walking the relation graph, and no longer repairs a crash-left checkpoint
transaction as a side effect of being asked for a health report.assay task validatestill recovers it.- Any subdirectory under
tasks/(or.assay/tasks/in an overlay) is now a
legal navigation prefix:tasks/research/deep/task-0007-<slug>/is the same
Task astasks/task-0007-<slug>/. Resolution is recursive and the stable id
remains the only identity, so moving a Task directory between prefixes by hand
is a supported operation that nothing needs to be told about — bindings,
relations, and Roadmaptask_refsall reference ids. A prefix carries no
schema and no metadata and never enterstask.json. An emptied prefix is
invisible rather than a finding, as is a note a reader leaves beside their
Tasks. Existing flat workspaces need no migration: flat is the trivial case of
the same resolution. tasks/archive/stays reserved and flat. A directory inside it that is not an
archived Task is reported with the repair rather than silently shadowing the
archive, and archiving a prefixed Task moves it totasks/archive/<id>/.
Below a prefix,archiveis an ordinary word:tasks/research/archive/holds
live Tasks.- The same stable id found under two prefixes goes through the existing
partial-health duplicate machinery, and the conflict message now names every
path it found instead of assuming a live/archive pair. assay task createstill lands in the flat root. There is no flag for filing a
new Task into a prefix; organizing is a deliberate move afterwards.- Test workspace fixtures now live under one run directory named by
ASSAY_TEST_FIXTURE_ROOTinstead of loose in the shared temp directory, so a
crashed run's leftovers are identifiable and removable in one sweep.
Migration notes
assay update on a 0.13.0 workspace rewrites source records as follows:
- A living checkout source becomes
content_mode: checkout; itscheckout/stays
where it is. - A frozen source, and a living source that used archive capture, becomes
content_mode: copy. Itscontent/is filled from the newest capture on disk,
or from the original source path when that is still present. If neither is
available the directory is created empty and the migration says so on that line. - An observation that preserved bytes becomes a
captureentry, keeping its
fingerprint as the capture's integrity value; its manifest is copied to
captures/<id>/manifest.json. - Any other observation becomes an
addorsyncappend record. Its retired
sha256-treevalue is written into the note rather than dropped. sources/<alias>/manifests/is left on disk untouched. No command reads it any
more; it is safe to delete once the migrated ledger looks right.- A manifest zone purpose that 0.13 shipped verbatim is rewritten to the current
vocabulary, soassay checkstops describingsources/as "Living and frozen
external evidence" after the concepts behind that sentence were removed. Only
exact stock strings are rewritten; a purpose someone edited stays theirs.
It rewrites Source adoption records as follows:
- Each mapping of each adoption becomes its own record. A single-mapping adoption
keeps its old id; anything else is suffixed with the mapping id. - The last committed decision for that mapping's target survives as a sentence in
the note:last 0.13.0 decision: <outcome> — <reason>. The old title and the
adoption and mapping it came from are named in the note as well. - An accepted baseline's source identity becomes the record's tier-1 pin — the
commit it accepted, or the tree hash when there was no commit. 0.13 never
recorded which origin a commit came from, so that stays null rather than being
guessed. A target that was never decided migrates without a pin, which is what
it always was. - Inspections, evidence records, decision chains, and rollback records are
dropped. The migration line counts each kind it dropped. .assay/source-adoptions/<adoption-id>/is left on disk untouched. No command
reads it any more; it is safe to delete once the migrated records look right.- A mapping this step cannot rewrite into a valid record is reported by name and
left in the old directory rather than written out half-understood.
Fixed
- A Task or Roadmap mutation on Windows could report failure after it had
already succeeded. Tidying the shared coordination directory away runs in the
mutation'sfinallyblock, and Windows answersrmdirwithEPERM— not
ENOTEMPTY— while a concurrent participant's claim directory is still in the
delete-pending state, so best-effort housekeeping threw over a completed
write. Every failure at that point means "leave it for the next caller", and
none of them surfaces now. - The Task lock protocol treated Windows delete-pending as a protocol violation.
A waiter reading the current lock owner gotEPERMfromlstatorrealpath
while the holder was removingowner.json, where POSIX would have said
ENOENT; the protocol already knew how to retry that exact situation but
recognized it under only one of its two names. Removals that have to land also
ride Node's documented Windows retry instead of failing on a transient
EPERM. Together these were the recurringEPERMon
coordination/workspace-mutation/owner.jsonin the Windows test runs.
Removed
- Ordinary
assay updateno longer creates or reports retained timestamp backups;
existing.assay/backups/content is left untouched.