Skip to content

Latest commit

 

History

History
633 lines (551 loc) · 37.2 KB

compatibility.md

File metadata and controls

633 lines (551 loc) · 37.2 KB

Compatibility and stability

This document defines what basou commits to under semantic versioning, so that adopters know which surfaces they can build on and which are still internal.

basou is pre-1.0 today. The 0.x line is being driven toward a 1.0 release aimed at broad adoption — the point at which the guarantees below are formally committed. This document describes the policy that 1.0 freezes; it is written before the freeze because parts of it (notably the on-disk format gate) cannot be retrofitted onto an already-frozen contract.

Guaranteed surfaces

At 1.0, semantic versioning applies to exactly three surfaces:

  1. The basou CLI — the set of commands and subcommands, their flags, exit codes, the documented --json output shapes of the commands that offer one, and the documented JSON input shapes of the commands that read one from stdin or an input-file flag (today: basou decision capture, basou review record, and the envelope of basou session import, whose version follows its own rule). An input shape is a contract for the same reason an output shape is — something else produces it, and tightening what is accepted breaks that producer exactly as surely as dropping a field breaks a consumer. Accepting MORE is additive; accepting less is not. For basou decision capture and basou review record, the documented shape is the Input format section of the command's --help, which is contract rather than presentation: the fields, their JSON types, which are required, and the values a field enumerates. Whether a repos entry of basou review record names a repository root on the machine is judged by the rule basou review-gaps binds a record with, and may change with that rule.
  2. The @basou/sdk package — its exported read-only API for reading a workspace's provenance.
  3. The .basou/ on-disk format — the durable file schemas (manifest, session, event, approval, task) and the JSON Schemas published alongside them.

Within a 1.x line these surfaces change only additively — a new command, a new optional flag, a new optional field, a new SDK export, an added field in a --json payload. Removing or changing the meaning of anything on a guaranteed surface is a breaking change and requires a major bump (2.0). The sections below, and the extension rules of schemas §7.3 for the on-disk format, state the exceptions to that rule and what bounds each of them.

A value a flag refuses

Within 1.x a flag may come to accept a value it refuses today, and that is additive, as accepting more of an input shape is — but only when every invocation accepted before is parsed and behaves as before. Making an option's value optional does not qualify: --file --json reads a file named --json today, and would parse --json as a flag instead. Accepting a refused value moves the invocations it accepts from a non-zero exit to 0, one of the two ways an invocation crosses zero within 1.x; the other, in the opposite direction, is a command coming to refuse a layout basou never creates. A split of the non-zero values never crosses zero.

One such value has its meaning fixed now, so that accepting it later cannot surprise anyone: - as the file of an input-file flag, a flag that names a file basou reads its input from — --file on basou decision capture, basou review record and basou note, --from-file on basou task new and basou session note, and --from on basou session import. Each of them refuses - today, and an input-file flag added later either refuses it too or reads standard input for it from the start. If one comes to accept it, - will mean standard input, never a file named -. A file named - is read by naming it ./-.

--version: the first token is the contract, the rest is build identity

basou --version prints the semver version as its first whitespace-delimited token, and that token is guaranteed. Everything after it identifies the build — the commit it was compiled from, and that commit's date — and may change at any minor, including gaining or losing fields.

The distinction is worth stating because the output is scraped: a release check that reads the first token keeps working across 1.x, and one that compares the whole line to a bare version string does not. Compare the first token.

The build identity is there because a version number cannot answer the question people actually ask when something behaves unexpectedly — which build is this. Two builds of one version differ only by commit, and that is the case that has actually gone unnoticed in practice.

Exit codes: zero is success, and a failure's value may be refined

What an exit code guarantees is exactly this: 0 means the command succeeded, and any other value means it failed — except for the two kinds of command below, which follow their own rule. Every other command exits 1 on a failure it reports, and a signal that ends it is reported the way a shell reports any process a signal ends (128 plus the signal's number) — except that basou view and basou refresh --watch take SIGINT and SIGTERM as the request to stop, and exit 0 on them. The 1 is not promised: a caller that tests for exactly 1 may see another non-zero value within 1.x.

The hook handlers exit 0 once their command line parses. basou hook stop and basou hook session-start are run by an agent's hook runner, and a hook that fails must not stop the agent, so once their arguments parse they exit 0 whatever the payload, the transcript or the workspace, including when they fail. Their outcome is not reported by their exit code. A command line that does not parse (an unknown option, a missing value) exits 1 as on any command, and a signal ends them as it ends any other process.

basou exec and basou run <adapter> pass their child's exit code through, because a wrapper that changed it would break a script that branches on the child's own values (grep exits 1 for no match and 2 for an error, for instance).

  • When the child exits with a code, basou exits with that code.
  • When the child is ended by a signal, basou exits with 128 plus a signal's number: the SIGINT or SIGTERM basou itself received while the child ran, when one reached it, and otherwise the signal that ended the child. Any other signal sent to basou ends basou as it ends any process, and the run's command_executed event is not written. basou exec --timeout ends the child with SIGTERM, then SIGKILL. The value is the same on every POSIX platform for SIGHUP (129), SIGINT (130), SIGQUIT (131), SIGKILL (137) and SIGTERM (143). Any other signal gives 128 plus its number on the platform basou runs on, the value a POSIX shell such as sh, bash or zsh reports there. Those numbers differ between platforms — 138 is SIGUSR1 on Linux and SIGBUS on macOS — so such a value is comparable only on one platform; the signal names recorded on the run's command_executed event (signal, received_signal) say which signal it was.
  • A signal Node has no name for — SIGEMT on macOS, the real-time signals on Linux — reaches basou as if the child had exited 0, and is outside the promise above. Today basou then exits 0, records an exit_code of 0 with no signal, and marks the session completed. None of that is promised: a later basou, or a later Node, may report such a signal. A reader cannot rule one out from an exit_code of 0.
  • When basou itself fails, it exits 1 — including when it fails after the child has exited, so a child that exited 0 is then reported as 1. A caller cannot tell a basou failure apart from a child that exited 1, and should not read 1 as "the child ran and failed".

The non-zero values of every command except these four and the experimental commands may be split within a 1.x line, so that a failure can say more than that it failed — for instance, that a command refused its input before writing anything, so resending a corrected input is safe. The carve-out is bounded by four things:

  1. A split never moves an invocation across zero. It changes which non-zero value a failure exits with, never whether it exits 0.
  2. A new value only divides a failure that already exists, and is documented with the write state it leaves behind: whether the command had written anything, to the .basou/ store or to any other file, before it stopped.
  3. Values are allocated once for the whole CLI. A new value is between 2 and 125 — 126, 127 and everything above 128 keep the meanings a shell gives them — and one value means the same thing on every command that uses it.
  4. It applies to the values of the exit code only — not to the CLI's flags or to any --json output shape, which stay under the additive rule above.

An experimental command exits 0 when it succeeds and 1 on every failure it reports, and takes no value from this allocation. A signal ends it as it ends any other command.

One thing about stdout is left open: a failed command is not promised to print nothing. A documented --json shape that a command prints when it exits non-zero — basou verify --json reporting a tampered session, for instance — stays under the additive rule like any other. Decide whether a command succeeded from its exit code, not from whether it printed anything.

basou verify verdicts may gain values on the failing side

The status and reason of a basou verify --json row may gain values within a 1.x line. The additive rule above names added fields, not added values, and verify's set of verdicts has to grow as its checks do — signing will need one. The carve-out is bounded by three things:

  1. A new status value always fails the command. It exits non-zero and never means a session passed; verified, unchained, empty, incomplete and in_progress remain the only values that exit 0.
  2. A new reason only refines an existing status, and never changes whether that status fails the command.
  3. A consumer reads a value it does not know as not verified. A consumer that switches exhaustively on status or reason should keep a branch for that.

unsupported, for a session a newer basou wrote, was added this way before 1.0. The row, its fields and today's values of both are listed in schemas §7.5.

A row whose status passes may also come to fail, as tampered, but only for a session in a layout basou never creates, as the next section bounds; the reason it gains names what is not followed.

A layout basou never creates may come to be refused

Within 1.x a command may come to refuse, or to stop following, an entry of the .basou/ store in a form basou never creates — a symlink, or anything other than the directory or regular file basou writes there — and so fail, or report a failure, where it succeeded before. A basou verify row that passed then fails as tampered: a log read through a symlink as verified, say. The SDK's reads follow their own form of this rule (item 4 below). This is the one way an invocation moves from 0 to a non-zero exit within 1.x, and it is bounded by three things:

  1. Nothing changes on a layout basou creates. On a workspace basou laid out, a command that succeeded still succeeds, and a row that passed still passes.
  2. The refusal names what it refuses: an error or a warning naming the entry, or a tampered row whose reason says what is not followed.
  3. The changelog lists each such change.

basou verify's symlink and not_a_directory reasons, for an entry named as a session that is not a directory, and its events_not_a_file and yaml_not_a_file reasons, for a session file that is not a regular file, were added this way before 1.0.

The import envelope version may move at a minor

basou session import and basou import require the envelope's schema_version to equal exactly one value, published as the const in session-import.schema.json. That value may move within a 1.x line, and a producer holding the previous one is refused.

This is stated rather than left to the general rule, because the general rule would freeze it until 2.0 and that is not the intent. The envelope describes a payload basou accepts at a boundary it does not write; when the durable formats it carries narrow, the envelope narrows with them, and pinning the envelope's version until a major would mean either a stale envelope accepting payloads the importer can no longer store, or a major bump for a change nothing on disk notices.

The carve-out is bounded by three things, and it is only defensible with all three:

  1. The refusal is loud. A wrong version is an immediate error naming the value received, the value expected and the artifact to read — never a silently dropped or partially imported payload.
  2. The version is discoverable at runtime. @basou/core ships the artifact, so a producer can read the const from the installed package instead of hard-coding it. A producer that pins the value by hand has opted out of this.
  3. It applies to the envelope's own version only — not to the CLI's flags, its exit codes, or any --json output shape, which stay under the rules above.

The on-disk format may make its gated changes at a minor

Schemas §7.3 allows two changes to a durable format only behind a version gate: widening a required field's domain (making it nullable, or giving an enumerated field a new value), and narrowing one to refuse a set that is empty. Both may be made within a 1.x line, under that gate.

This is stated rather than left to the general rule, because the general rule would read "additive" from the side of a consumer of a shape, and what the format guarantees is a different thing. Across 1.x, a newer basou reads every document an older one wrote, with the meaning it was written with. The reverse is not guaranteed — forward tolerance is a design goal (see On-disk format versioning) — and changes the extension rules already allow without a gate lose lines in an older reader just as a widened value does: a new event type, and a new optional field on an event variant that rejects unknown keys, are both dropped by a basou that does not know them. Forbidding only the gated changes would therefore not protect an older reader. It would close the one route that ships a read rule.

The carve-out is bounded by five things, and it is only defensible with all five:

  1. The format's version moves. Its schema_version is bumped, its published $id moves with it, and the artifact the previous $id served is kept under schemas/retired/.
  2. A read rule that interprets a value is code, not only prose. When the rule says how to read a stored value, rather than that nothing already stored changes, it has one implementation, exported from @basou/core and re-exported from @basou/sdk, as readObservedDuration is for command_executed.duration_ms.
  3. No value already on disk changes meaning. A bump may only assign a meaning to a value the field could not hold before.
  4. The changelog says what an older reader does. The change is listed as breaking to that format, and names the releases of basou that cannot read what it newly writes and what they do instead: skip the line or the document, or fail the command that reads it.
  5. It applies to the on-disk format, to the types @basou/sdk re-exports from it, and to a --json payload that prints stored values as they are stored (basou session show --json prints a session's events, for instance) — not to the CLI's flags, its exit codes, the rest of any --json output shape, or the SDK's functions. A function whose signature names a format type (readEvents returns Event[]) carries the change through that type and is otherwise unchanged.

The consequences are accepted rather than avoided. A union type the SDK re-exports from the format (the change_type of FileChangedEvent, for instance) can gain a member at a minor, and a field can become nullable; a consumer that checks such a type exhaustively should keep a branch for a value it does not know. And a reader older than the basou that wrote a store skips or fails on what it cannot read: keep @basou/sdk at least as new as the CLI that writes the store it reads, and upgrade together every host that shares a store through ~/.basou/hosts.yaml.

Advisory surfacers: the command is guaranteed, the payload evolves

basou review-gaps and basou decision gaps are advisory surfacers: they answer "what looks like it was missed", they write nothing, and they enforce nothing. They sit on the guaranteed CLI surface — the commands and their flags stay under the additive rule — with one carve-out for the --json payload:

  1. The payload's field set and the meaning of its counts may change within a 1.x line. What a surfacer looks at is an ongoing judgement about what reads as a gap — a population boundary, a ground for excluding an entry, a caveat about what could not be read — and freezing today's answer to 2.0 would mean either never improving it or bumping the major to do so.
  2. What is guaranteed is that the payload stays self-describing. Every boundary the run applied is reported in the payload itself (for instance scope), so a consumer reads the run's own account of what it checked rather than assuming last release's rule. A count never silently changes meaning while keeping its name: a ground that changes gets a new name.
  3. It applies to these commands' --json payloads only — not to their existence, their flags, or their exit codes, which stay under the rules above.

A consumer that needs a frozen shape should read the store through @basou/sdk and apply its own rule.

Observed files: six properties are guaranteed, the attribution may improve

For an imported session, part of session.yaml's related_files is observed from git rather than read from the transcript (schemas §5.2). Deciding which of a repository's commits are the session's own is an ongoing judgement that rests on how git records its operations: which reflogs are read, which of git's reflog messages count as a commit being created, the cases in which no limit is applied, and the limits §5.2 lists. That attribution may change within a 1.x line.

  1. What is guaranteed is the six properties §5.2 lists — five things the observed files never contain and one thing they always do — and a change to the attribution keeps all six.
  2. A change to the attribution is listed in the changelog. The stored list does not record which release derived it, so the changelog is the only place that says a list may differ because the rule changed rather than the work.
  3. It applies to the observed half of related_files only — not to the paths taken from the transcript's own tool calls, not to file_changed events, and not to any other field of session.yaml.

SDK errors: the class is the contract, the message is not

Every error class @basou/sdk defines extends BasouSdkError, and today each of them is a refusal: an ambiguous id prefix, a path with no usable workspace, or a directory of the store the SDK does not read through. What a caller can rely on is what it reads off such an error: its class, tested with instanceof; its name, the name of the class as the SDK exports it, which the SDK sets from a string so that a build that renames classes does not change it; the fields the class documents (.root, .input); and which reads throw it, and when, as the class and Workspace document it. Every other failure propagates from @basou/core or its dependencies as it is — a malformed record, a record a newer basou wrote, an I/O failure — and its class and message are not covered.

The classes are nominal to TypeScript. Only an instance of a class or of one of its subclasses is assignable where the class is typed — not a sibling, a parent, a plain Error or an object literal of the same shape — so a field added to a class is additive like any other new field. Two installed copies of @basou/sdk at different versions therefore have distinct error types, as their classes are already distinct at runtime: an error from one copy is not an instanceof the other's class. A library that takes or returns SDK errors should declare @basou/sdk as a peer dependency, so that it shares the application's copy.

Four things about an SDK error are not guaranteed, and may change within a 1.x line:

  1. The message. It is written for a person to read and may be reworded at any release. A StoreUnsafeError's message is the one @basou/core gives, and core is not a guaranteed surface. Tell errors apart by class and by documented field, never by matching the message.
  2. The constructors. An SDK error is for the SDK to throw and a caller to catch; constructing one is not covered. A constructor may gain a required parameter — when a class gains a field the SDK fills, for instance — and a test that stands in for the SDK by constructing its errors has to follow that change. Code that holds a class only to test instanceof against it should type it by what it constructs, as abstract new (...args: never) => StoreUnsafeError, rather than as typeof StoreUnsafeError, which also names the constructor's parameters.
  3. The cause. When an SDK error carries the error it was made from, that is on cause, for diagnosis. Whether there is one, and what it is, may change: the SDK may come to detect a condition itself rather than retype core's error.
  4. Extending a class. A consumer's subclass of an SDK error class is not covered: a field the SDK adds can collide with one the subclass declares, and the subclass calls a constructor that is not guaranteed.

The errors may grow. Within a 1.x line:

  1. A failure that propagates today may come to have an SDK class of its own, extending BasouSdkError — a record a newer basou wrote, for instance.
  2. A condition may come to throw a subclass of the class it throws today, for part of that condition — a .basou/ that is a symlink, apart from one that is missing, for instance.
  3. A store the SDK comes to read gets a StoreUnsafeError subclass of its own, and a read may begin to throw for a store it did not read before.
  4. A read may come to refuse, or to stop following, an entry it reads through today when the entry is in a form basou never creates: a symlink under .basou/ outside a view placed there, something other than a directory where basou makes a directory, or something other than a file where a file belongs (an approval file that is a directory, for instance) (workspace). A refused directory of a store throws that store's StoreUnsafeError. An entry that is not followed is treated as a session entry that is not a directory is today: it is left out of a list and looked up as null, and a prefix it shares with a record is ambiguous, so a prefix lookup of that record throws AmbiguousIdError. Where a record has two entries, as an approval has a pending and a resolved file, the one that is not followed is passed over and the record is read from the other.

The carve-out is bounded by three things:

  1. Nothing changes on a layout basou creates. Items 1 and 2 only retype an error a call already throws. Items 3 and 4 apply only to a layout basou never creates: on a workspace basou laid out, a read does not begin to throw a StoreUnsafeError, and does not begin to leave an entry out.
  2. A new class is nominal from its first release. It extends the class whose condition it divides, StoreUnsafeError for a store, or BasouSdkError; it carries a brand of its own, so that no class it does not extend is assignable to it; and its name is its class's name, set from a string.
  3. The changelog lists each change, and the class and Workspace documentation list which reads throw which class.

A consumer handles the classes it knows and keeps a branch for the rest. instanceof keeps matching a subclass added later, so catching StoreUnsafeError handles every store's refusal, including a store added later; a switch on name does not, so it needs a default branch, for a class it does not know and for a subclass of one it does.

What is not guaranteed

  • @basou/core is published on npm but is not a semver-guaranteed API. Core exists so the CLI and SDK can build on it and so advanced consumers can embed basou, but the bulk of its exports are CLI-internal planning helpers (for example the archive, gitignore, and retrofit planners) that would become a permanent constraint if frozen. Depend on @basou/sdk for a stable read API. A named subset of core may be promoted to the guaranteed surface in a future release; until then, treat core as internal.

  • The CLI's human-facing prose is presentation, not contract. The orientation narrative, nudges, and other rendered prose may be refined at any time — do not scrape it. Machine consumers have two covered read paths instead: @basou/sdk, and the --json output of the commands that offer it, other than the experimental commands below (part of the guaranteed CLI surface above).

  • Experimental commands are outside every guarantee in this document, except the ones stated here. Each row of the table below names a top-level command. It and every subcommand under it, including one added later, are experimental; a subcommand or flag added to a command that is not in the table is guaranteed from its first release. An experimental command may change its flags, its output and input shapes, and the files it reads or writes, or disappear, at any release. This overrides the deprecation policy, including the 0.x freeze. What still holds:

    • Its exit codes follow the rule stated for experimental commands under exit codes, which replaces the allocation for them.
    • The invariants.
    • It may read the .basou/ store but writes nothing under .basou/.
    • Only an invocation of a listed command is exempt. A guaranteed command keeps every guarantee whatever an experimental command has written: it does not fail, exit differently, or change a documented --json shape because such a file exists, is missing or has changed shape. It may show what one wrote only in prose or on a basou view page.

    A command can be experimental only from the release that introduces it; an existing command is never moved onto this list. Promotion is one-way: a command leaves the list in a minor release, and from that release the normal rules apply to it, together with the files and shapes the promoting release names as guaranteed; anything it does not name stays outside every guarantee. A listed command may instead be removed at any release, and its row with it. Removal is not promotion, and a name that returns later is a new command. --help marks each of these commands [experimental]; the table, not the mark, is what decides.

    Command Experimental since
    basou board 0.65.0
  • basou view's HTTP routes and pages are a local UI, not an API. Read the trail through @basou/sdk or the --json output of a command that is not experimental instead.

On-disk format versioning

Each durable file carries a schema_version (the manifest additionally records a basou_version). This tracks the on-disk format major, which is decoupled from the npm / product version:

  • Shipping product 1.0.0 does not bump the format major. The format major stays at 0. Seeing schema_version: 0.x on a 1.0+ install is therefore expected and does not mean the format is unstable.
  • A MINOR bump within format major 0 can still be breaking, and one has happened: 0.2.0 made command_executed.duration_ms nullable (see schemas §7.3). So 0.x does not by itself mean "nothing incompatible has changed since 0.1". Read the minor. More such minors may follow within a 1.x line of the product, under the bounds in The on-disk format may make its gated changes at a minor.
  • basou reads format major 0: it accepts any 0.x.y schema_version and gates a higher / unknown major (1.x.y+) with an explicit "upgrade basou" error rather than a cryptic field-level parse failure. That gate is major-only, so it does not catch a breaking MINOR: a basou at 0.41.0 or earlier rejects a 0.2.0 event with a null duration_ms and drops the line, losing the whole event rather than reporting an upgrade. Hosts that share a store through ~/.basou/hosts.yaml should therefore be upgraded together. The manifest, task and approval records are loose objects that keep unknown fields when basou rewrites them, so a newer minor's additive fields survive that (basou init --force replaces the manifest rather than rewriting it). session.yaml is a loose object too, except integrity, which rejects a key it does not know and so makes the whole session unreadable. Adding a key to it therefore moves the session's schema_version (schemas §7.3), and a basou verify from 0.57.0 on reports such a session unsupported rather than tampered (0.56.0 and earlier report it tampered). An imported session's session.yaml is rebuilt from its source whenever the source grows, keeping only task_id and summary, so a field a newer minor added does not survive a re-import. Events keep an unknown field only inside approval_requested.action, which passes it through. At the top level, a few event variants are intentionally strict and reject a line carrying a key they do not know, and every other variant accepts the line but drops the unknown field from what it reads — and a re-import writes the events it keeps from what it read. So forward tolerance within major 0 is a design goal, not a per-record guarantee.
  • The published JSON Schemas for the durable formats carry the matching pattern (^0\.\d+\.\d+$) in place of an exact const, so a cross-language validator enforces the same major. (Cache schemas keep an exact const — see below.)

This gate behavior is itself part of the frozen format contract. It is defined before the 1.0 freeze because a forward-compatible acceptor cannot be added after the version is pinned to an exact literal — an old reader would already reject anything it had not seen.

Migration machinery is deliberately deferred. Only the gate is frozen now; the transform that would carry an old format major forward to a new one is future work, to be introduced when the format first changes incompatibly.

Caches are exempt. Derived cache files are pinned to an exact literal version and rebuilt on mismatch, so they are not part of the forward-compatible durable contract.

Deprecation policy

For the 0.x line the CLI surface is already treated as frozen — commands and flags are stable. When a flag becomes obsolete before 1.0, it is kept as a deprecated no-op (still accepted, prints a warning that it is now ignored) rather than removed, so an existing script that passes the flag keeps working instead of erroring on an unknown option. Deprecated no-op flags are removed at 1.0. None of this paragraph applies to the experimental commands listed under What is not guaranteed.

Example: basou init --repo-url became a no-op when project.repository_url was removed from the manifest (a value nothing read and that drifted silently). The flag is accepted-and-ignored through 0.x and dropped at 1.0.

The same policy covers a field that becomes required on a documented JSON input shape. Within a 1.x line that is a breaking change and waits for 2.0; the path below is how it is introduced, at a major or before 1.0. It is warned about for at least one release first — the item is still accepted and still written, and the full text of the eventual error goes to stderr — before omitting it becomes an error. The warning release is a chance to be told, not a guarantee of being told: a producer that upgrades straight past it, or that never reads stderr, meets the error without having seen the warning. So a producer should keep its input until the command exits 0. That rule rests only on the exit-code guarantee above, and so it holds whether or not a warning ever reached the producer. A non-zero exit does not by itself say that nothing was written.

Example: "kind" on basou decision capture. It was optional, and omitting it filed an unfinished direction as a settled decision, which never enters the open-track list: the next decision takes its place, instead of it being held until closed — a failure with no symptom. 0.47.0 warned on every omission and still wrote the item; from 0.48.0, released the next day, it is an error, and omitting it on ANY item refuses the whole batch. Through the warning release an explicit "kind" was carried onto the event, so an item written WITHOUT a declared vessel stays distinguishable from one written with it. Events recorded before 0.47 predate that distinction: an absent kind there means only that nothing was recorded, not that nothing was declared.

Refusing the whole batch, rather than writing the valid items and reporting the rejected indices, is what keeps the success line honest: a partial write reports success while holding fewer items than the caller handed over, which is the same shape of silent miscount the requirement was introduced to remove. It also keeps the rule consistent with every other validation this command performs — a malformed field has always refused the batch. Every refusal ends by naming the number of items NOT written, so it can never be read as a partial success, and a missing "kind" names the offending indices — up to ten, with any remainder collapsed into a stated count.

A field that becomes required on one command does not oblige every command that writes the same event to grow the same requirement. decision_recorded has three writers — the transcript importer, basou decision capture and basou decision record — and only capture reads a JSON input shape. record's vessel is the --track flag, which has no way to state "explicitly not a track"; the importer declares no vessel at all and cannot be given one. The requirement therefore lands on the input shape. record instead names the vessel it used on its receipt, which needs no new flag on a surface this line treats as frozen.

Since this policy was adopted (0.31.0), a flag has also kept what it accepts, with one exception: - as the file of an input-file flag, refused before 1.0 so that 1.0 does not freeze it as a file name in the way of the reservation. Four flags read it as a file named - until the release after 0.68.4: --file on basou decision capture, basou review record and basou note, and --from on basou session import. It was refused outright, with no warning release. A warning release exists so that a caller does not lose input it cannot easily make again; here the input is a file, which stays on disk and is read as ./-, as the refusal says. No other value is refused this way before 1.0.

After 1.0, removing or changing the meaning of any guaranteed-surface element requires a major bump; additions within a line remain backward-compatible.

Invariants that hold regardless of version

These are properties of the design, not of any particular release, and are not expected to change across major versions:

  • Local-first and zero-network — the workspace trail lives under .basou/ next to your code; optional integrations may also write user-level files (~/.claude/, ~/.codex/), and an experimental command may write files beside a file of yours that it reads, never under .basou/ (basou board record writes a records/ directory beside its board.yaml); those files are not part of the trail, though they may hold what the trail holds. Everything stays on-machine — nothing is sent off-machine.
  • Adopt, not rip — adoption is non-destructive and reversible, and the adoption / wiring generators (sync, adopt, gitignore, symlinks) are dry-run-by-default.
  • Runtime does not depend on an LLM — triggering and derivation use deterministic proxies only.