Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
109 KiB
status, created, updated, owner
| status | created | updated | owner |
|---|---|---|---|
| draft | 2026-08-19 | 2026-08-20 | tbd |
Automations YAML Export
Why
A workspace's automations are operational prose with no version control. Measured
against the live store on 2026-08-19: 7 automations, 78,589 bytes of prompt across
1,338 lines, spread over 3 workspaces. The largest single automation prompt is 404
lines, longer than most workflow steps. All 7 carry a webhook_secret.
None of it is reviewable. There is no diff, no history, no rollback, no way to see what a prompt said last week. Changing a 56-line automation prompt required dumping the row out of SQLite to a temp file by hand purely to produce something a human could read as a diff.
Workflows already solved this: internal/workflowsync keeps workflow definitions in a
git repository and applies them on a poll. Automations, which carry far more prose than
workflows do, got nothing.
This spec covers export only: rendering a workspace's automations as YAML a human
can commit, diff, review, and roll back by hand. The applier that reads that YAML back
is deliberately out of scope (see ## Out of scope).
What
A user can export a workspace's automations as YAML, in two forms:
- A single YAML document containing every automation in the workspace, for reading and for API consumers.
- A zip of one file per automation, laid out as
.kandev/automations/<slug>.yml, which is the shape a human unpacks into a repository so each automation gets its own diff.
The exported YAML is round-trip readable: everything needed to recreate the automation by hand is present. Secrets and runtime state are not.
Decisions taken (rationale in ## Decisions):
webhook_secretis never exported, and the export type is a purpose-built DTO rather than theAutomationdomain struct, because the existingjson:"-"tag does not redact under a YAML marshaller.- Scheduler state is never exported.
last_evaluated_at,created_at,last_triggered_at, andupdated_atare excluded. The first two are the cron scheduler's fire anchor. - Foreign keys are exported as portable descriptors, not UUIDs. A UUID is not "enough to recreate the automation by hand".
- Trigger config is carried as an order-normalized generic mapping, not decoded into per-type structs, so a config key the exporter does not know about survives.
- Output is byte-deterministic for a given database state, with every ordering resolved by a named column. This binds the zip archive's bytes as well as the YAML.
- Numbers in trigger config are copied character-for-character, never converted through a Go numeric type, because every conversion route corrupts some class of input — rounding large integers, adding exponents to plain ones, or quoting all of them.
- Prompt style and prompt fidelity are checked separately. Whether a prompt is readable in the diff and whether its bytes survived are independent questions; a prompt beginning with a newline fails the second while passing the first. Where the marshaller's default choice would lose bytes, the export re-quotes that prompt into a form probed to preserve them, and says so.
- Every JSON type in trigger config has one stated node form. Numbers are untagged so their
lexeme survives; strings are explicitly
!!strso a value readingtruestays a string. - Denial and absence are the same observable response (
404), so the endpoint cannot be used to enumerate which workspaces exist.
Definitions
Two terms are load-bearing in more than one acceptance criterion. They are defined once, here, and every AC that uses them means exactly this and nothing else.
newline — any character in the set {U+000A, U+000D, U+0085, U+2028, U+2029}. A string
"contains a newline" when at least one of its characters is in that set; it "contains no
newline" when none is. Used by AC-17, AC-46 and AC-42.
(This is YAML 1.2's own line-break set, and it is exactly the is_break set in yaml.v3's
yamlprivateh.go that ## Decisions already quotes. Two properties make it safe to name here
where the deleted degradation-condition list was not. It is normative and fixed — it comes
from the YAML specification rather than from an attempt to predict an emitter's behaviour, so it
cannot drift out of date the way a list of "causes of degradation" did across three revisions.
And it answers a different question: not "will this emit as a block scalar" (which stays an
observation, per AC-17) but "is this prompt multi-line at all", which is a property of the string
and of nothing else.
The narrower reading — U+000A alone — was rejected and the reason is measured. Probed, a CR-only, NEL-only, LS-only or PS-only prompt all emit non-literal: a prompt a human wrote as several lines collapses to one quoted line. Under the narrow reading those prompts satisfy AC-46, which mandates no warning, so the single most useful diagnostic this feature offers would be withheld from exactly the prompts that need it. Under this definition they reach AC-17 and warn.
Consequence for AC-42, made explicit rather than left to a reader: because the set is wider than
{LF, CR}, AC-42's rendering rules escape all five, and its "No other character is altered"
clause is stated against the completed four-rule list below rather than against a two-rule one.)
start (of an export) — the moment the export's read transaction establishes its snapshot,
which is the moment it issues its first read inside that transaction, not the moment BeginTxx
returns. AC-29 requires the export to establish its snapshot immediately upon opening the
transaction, before any other work, so the two moments are adjacent and the term is unambiguous.
Used by AC-13 and AC-30.
(SQLite's deferred transaction takes no snapshot at BEGIN; the first read does. Left undefined,
AC-13 was falsifiable by a correct implementation: export A opens at t0 and first-reads at t0.5,
export B opens at t1 and first-reads at t3, a write commits at t2. Nothing committed between the
two BEGINs, so AC-13's antecedent held and demanded byte-identical output, while A and B held
different snapshots and were required by AC-30 to differ. Pinning "start" to snapshot
establishment, and requiring establishment to happen immediately, makes both ACs true together.)
Decisions
Export is additive and read-only
This card is scoped "no backend change". That is read as: no applier, no schema migration, and no change to any existing backend behaviour. The export path itself is new backend code, because:
- Phase 2 is specified as mirroring
internal/workflowsync, a backend package. An exporter living outside the backend would give that mirror nothing to agree with. - The artifact is a product capability delivered to a user, not a one-off script.
No existing behaviour is modified. The export reads; it never writes to automations,
automation_triggers, automation_repositories, or automation_runs.
"Additive" is about behaviour, not about file count, and it does not forbid new code in
existing packages. AC-29 requires a single read transaction and the automation store has no seam
for one — every read path issues directly against the reader handle and takes no transaction — so
the export needs new transaction-accepting read methods on Store. Adding them is in scope.
Four stores are in scope for that addition, not one. AC-29 covers the rows a portable descriptor is resolved from as well as the automation's own rows, and those live in three other packages, so new exported transaction-accepting read methods are authorized on the automation store, the agent-settings store, the workflow repository, and the repository store. Each new method is a read, takes the transaction as a parameter, and sits beside the existing signature rather than replacing it.
What is out of scope, in all four, is changing what any existing method does to any existing caller: the current signatures stay, the new methods sit beside them, and no firing path, scheduler path, or WS handler changes behaviour.
(An earlier revision scoped this sentence to Store alone — automation's own — while AC-29
simultaneously required descriptor rows to be read inside the same transaction. Since no existing
method on the other three stores accepts a transaction, the two statements could not both be
honoured, and the builder was left to choose between exceeding the stated scope and writing query
text into backendapp. Naming the four stores here is what makes AC-29's mechanism authorized
rather than improvised.)
webhook_secret needs an explicit exclusion, not an inherited one
Automation.WebhookSecret is tagged json:"-" db:"webhook_secret". It carries no
yaml tag. gopkg.in/yaml.v3 — the marshaller workflowsync already uses — does not
read json tags. Verified by marshalling the field layout through yaml.v3:
id: abc
name: "n"
webhooksecret: SENTINEL-SECRET-VALUE
lasttriggered: null
Two failures in four lines. The secret is emitted under webhooksecret, and
json:"...,omitempty" is ignored too, so nil runtime timestamps serialize as explicit
null rather than being dropped. All 7 live automations carry a 32-character secret, so
the first is not a hypothetical.
The export type is a separate DTO with explicit yaml tags on every field. The domain
struct is never marshalled directly. AC-6 and AC-7 exist to hold this closed.
Scheduler state is excluded because it is the fire anchor
CronScheduler.shouldFire computes the next fire time from an anchor that is
trigger.LastEvaluatedAt, falling back to trigger.CreatedAt when the trigger has never
been evaluated. Both fields are therefore live scheduler state, not definition. Four of
the seven live triggers have last_evaluated_at set; the other three are anchored on
created_at.
Excluding both from the export means a future applier physically cannot carry a stale
anchor into the database, which is the drift-and-double-fire failure mode called out for
Phase 2. Their exclusion also keeps the diff quiet: last_evaluated_at changes on every
tick, so an export that included it would show a diff every hour for an hourly
automation, and the signal would be lost.
Foreign keys become portable descriptors
An automation references five things by UUID. Exporting the UUIDs would satisfy nothing: a UUID is neither reviewable in a diff nor sufficient to recreate the automation by hand.
| Reference | Exported as | Rationale |
|---|---|---|
agent_profile_id |
{agent_name, model, mode} |
Reuses the existing wfmodels.AgentProfilePortable triple, already the established portable identity for an agent profile (backendapp/services.go). Profile names are not unique — the live store has two profiles named Opus - Low — so name alone is not viable; the triple separates them (opus[1m] vs default). |
executor_profile_id |
{executor, name} |
No portable type exists for executor profiles; this spec defines one. executor_id alone is insufficient because it is a stable slug for built-ins (exec-local, exec-worktree) but a UUID for custom executors — the live store has one such profile, neo. Both fields are emitted so a reader can match on either. |
workflow_id / workflow_step_id |
{name, step} |
Workflows are already git-synced and already identified by name in ApplySyncedWorkflows. Name reference is consistent with the entity's own sync identity. |
repository_ids |
ordered list of repository names | Repository name is the human handle shown in the UI. |
Where a referenced row cannot be resolved, see AC-19: the export does not fail.
Trigger config is a generic mapping, not a typed struct
automation_triggers.config is stored as raw JSON text and is not canonicalized on
write. The live store holds two different serializations of the same shape:
{"cron_expression":"0 9 * * *","timezone":"Asia/Singapore"}
{"cron_expression": "@daily", "timezone": "Asia/Singapore"}
Emitting the stored text verbatim would make byte-identical automations diff against each
other. Decoding into the typed per-trigger structs (ScheduledTriggerConfig and friends)
would normalize the formatting but would silently drop any key the struct does not
declare.
That second failure is precisely the defect shipped by Office's config export/import,
where reports_to is present in the DTO and emitted by export but dropped on import. The
lesson is taken rather than repeated: config is decoded to a generic mapping and
re-emitted with keys sorted, which normalizes formatting and preserves unknown keys.
A consequence worth stating: a new trigger type needs no exporter change.
Numbers are copied, not converted
Decoding trigger config into a generic mapping puts every JSON number through Go's any, and every
route that converts the number corrupts some class of input. Probed against
{"big":9007199254740993,"huge":9223372036854775808,"round":1000000000,"exp":1e9,"onepoint":1.0}:
json.Unmarshal -> map[string]any big -> 9.007199254740992e+15 VALUE CHANGED
round-> 1e+09 reformatted
UseNumber + Int64()/Float64() exp -> 1e+09 an integer inside int64, now exponential
huge -> 9.223372036854776e+18 VALUE CHANGED
onepoint -> 1 a float, now an integer
UseNumber, no conversion big -> "9007199254740993" NOW A STRING
round-> "1000000000"
Plain decoding makes every number a float64, which rounds 9007199254740993 and reformats
1000000000 as 1e+09. Emitting the json.Number directly fixes the precision loss and
introduces a worse bug: json.Number is declared type Number string, so yaml.v3 emits it
quoted and max_retries: 3 comes back as max_retries: "3". The Int64()-then-Float64()
route looks like the fix and is not: it branches on lexical form — strconv.ParseInt rejects
1e9 and 1.0 outright, and cannot hold 9223372036854775808 — so three separate input classes
each come out wrong, two of them silently.
The mechanism that survives all of them is not to convert at all. Decode with UseNumber to keep
the original characters, then hand yaml.v3 a *yaml.Node scalar carrying that exact lexeme.
Leave Tag unset. An earlier revision of this spec assigned !!float when the lexeme
contained ., e or E and !!int otherwise, on the theory that each lexeme resolves to its tag
implicitly so nothing would be printed. That is true only inside [-2^63, 2^64). yaml.v3's encoder
drops an assigned tag only when its own resolve() of the raw lexeme returns the same tag; for a
20-digit integer resolve() falls through ParseInt and ParseUint to !!float, and for 1e400
ParseFloat returns ErrRange and it falls through to !!str. Either way the assigned tag now
disagrees and is printed. Probed side by side:
lexeme Tag unset Tag assigned !!int/!!float
1000000000 1000000000 1000000000
1e9 1e9 1e9
1.0 1.0 1.0
9007199254740993 9007199254740993 9007199254740993
9223372036854775808 9223372036854775808 9223372036854775808
18446744073709551616 18446744073709551616 !!int 18446744073709551616
99999999999999999999999999 99999999999999999999999999 !!int 99999999999999999999999999
1e400 1e400 !!float 1e400
-12345678901234567890 -12345678901234567890 !!int -12345678901234567890
Unset wins on every row and loses on none. It is also safe: a JSON number lexeme can never collide
with YAML's keyword set (true, false, null, yes, on, ~) because the JSON grammar
excludes them, and encoding/json rejects NaN, Infinity, a leading +, leading zeros and
0x forms during scanning, so those never reach the exporter as numbers at all — a config
containing them is not valid JSON and routes to AC-39 instead. Deleting the tag assignment is
therefore strictly simpler than the rule it replaces and removes the last class of altered output.
This is the same principle the spec already applies to prompts (AC-15) and to unknown config keys (AC-11): the export reports what is in the database rather than normalizing it, and AC-34 already states that outright. A number is not a special case; it only looked like one because the obvious implementations reach for a Go numeric type on the way past.
Ordering is explicit because the store's ordering is not stable
ListAutomations orders by created_at DESC and trigger hydration orders by
created_at, neither with a tiebreak. Three of the seven live automations were created
within 5 milliseconds of each other by a bulk path, so equal timestamps are a
realistic collision, and SQLite's row order for a tie is not defined.
A git-committed artifact whose row order can shuffle between runs produces phantom diffs. Every ordering in the export is therefore pinned to a named column with a named tiebreak (AC-8).
Prompts are emitted faithfully, and degradation is observed rather than predicted
The whole value of this feature is a readable diff of a long prose prompt. yaml.v3 emits a
multi-line string as a literal block scalar (|) only when the string qualifies. When it does
not, the entire value collapses to one double-quoted line — applied to the 404-line prompt in the
live store that is a 24 KB single line: syntactically valid, completely unreviewable, and it
happens silently.
The export therefore observes the emitted form rather than predicting it. That is a correction to three earlier revisions of this spec, and the reason is measured rather than stylistic. Each revision tried to enumerate the conditions under which yaml.v3 declines a block scalar, and each enumeration was found incomplete by the next review:
| Revision | Enumerated | Missed |
|---|---|---|
| First | trailing-whitespace line, CR, invalid UTF-8 | every astral character, every C0/C1 control, DEL, BOM |
| Second | + C0 controls, DEL, astral characters | the C1 controls U+0080–U+009F, U+FEFF, U+FFFE, U+FFFF |
| Third | + C1 controls, BOM, U+FFFE, U+FFFF, "a U+0020 followed by a line break" | U+2028 and U+2029 |
The third miss settles the argument. emitterc.go › yaml_emitter_analyze_scalar computes
block_allowed = !(trailing_space || space_break || special_characters), where space_break is a
U+0020 followed by any character in is_break — {U+000A, U+000D, U+0085, U+2028, U+2029}
(yamlprivateh.go) — while is_printable admits U+2028 and U+2029, so special_characters
does not catch them either. A prompt containing a space immediately followed by U+2028 thus
matched no enumerated condition and still lost its block scalar. Probed against v3.0.1: it emits
double-quoted on one line.
Each round the list was extended and each round a new gap appeared, because the spec was maintaining by hand a predicate the library computes from three interacting expressions over five character classes. The structural fix is to stop maintaining it.
Mechanism. The emitted style is directly observable: yaml.v3 records Style on the node it
parses, so re-parsing emitted output into a yaml.Node and testing Style & yaml.LiteralStyle
answers the question exactly, with no predicate to keep in sync. Probed against v3.0.1:
prompt value Style Tag literal? bytes preserved?
"line one\nline two\n" Literal !!str yes yes (emits |)
"line one\nline two" Literal !!str yes yes (emits |-)
"line one\nline two\n\n" Literal !!str yes yes (emits |+)
"line one\ta\nline two\n" Literal !!str yes yes (TAB does not degrade)
"line one \nline two\n" DoubleQuoted !!str no yes (space before a break)
"line one\nline two " DoubleQuoted !!str no yes (trailing space)
"line one\r\nline two\r\n" DoubleQuoted !!str no yes (CR)
"line one\na<U+1F389>b\n" DoubleQuoted !!str no yes (astral)
"line one\na<U+001B>b\n" DoubleQuoted !!str no yes (C0 control)
"line one\na<U+2028>b\n" DoubleQuoted !!str no yes (the miss above)
"Do the thing" Plain !!str no yes (single line; not a degradation)
"Do the thing " SingleQuoted !!str no yes (single line; not a degradation)
"line one\nbad\xff\xfebyte\n" — !!binary no yes (base64; AC-47)
"\nhello" Literal !!str yes NO -> "hello" (AC-49)
"\n" Literal !!str yes NO -> "" (AC-49)
"\n\nhello" Literal !!str yes NO -> "\nhello" (AC-49)
"\n\n" Literal !!str yes NO -> "\n" (AC-49)
The last four rows are the reason this spec has an AC-49. A prompt that begins with a newline loses that newline, and the emitted scalar is a perfectly ordinary literal block scalar, so a style check sees nothing wrong. Style answers "is this readable"; it does not answer "are these the same bytes". The two questions are independent and the export must ask both.
Order of operations, because the naive reading is circular. The warning lives inside the
document (AC-20), but the style is only knowable once something has been emitted. The export
therefore determines each prompt's disposition before building the final document: for each
automation it marshals that prompt alone through an encoder configured identically to the real one
(AC-12's 2-space indent, block context), re-parses that throwaway output, and reads three
things from the resulting node — its Tag, its Style, and its Value. The final document is
then built once, warnings included, and marshalled once. It shall not be marshalled,
inspected, amended and re-marshalled — a two-pass emit makes AC-9's byte-determinism depend on
the two passes agreeing, and gives AC-48 a body that existed in an earlier form.
Classification is ordered, and the order is normative. Exactly one branch is taken per prompt, so exactly one prompt warning is possible per automation:
Tagis!!binary→ AC-47. Do not override the style; do not apply the fidelity test in step 2, whose comparison is meaningless here (the re-parsed node'sValueis the base64 text, not the prompt, so a naive comparison reports a loss that has not occurred). Fidelity for this branch is established by base64-decoding the emitted value, which is probed byte-identical.Valuediffers from the stored prompt → AC-49. Emit this prompt in the final document as a*yaml.NodecarryingTag: "!!str"andStyle: yaml.DoubleQuotedStyle, and warn.Styleis not literal and the prompt contains a newline (as## Definitionsdefines it) → AC-17. Warn; do not override the style.- Otherwise → no prompt warning.
Steps 1 and 2 are ordered relative to each other by measurement, not by preference: forcing
Tag: "!!str" and a double-quoted style onto an invalid-UTF-8 prompt produces output that
fails to re-parse at all, so applying step 2 before step 1 would turn a handled case into a
broken document.
The standalone probe is faithful because yaml_emitter_analyze_scalar derives block_allowed
from the scalar's own bytes, and encode.go selects the literal style for any string containing a
newline except in flow context. This export emits block context throughout and never sets flow
style, so the probe and the real emission cannot disagree. A conforming implementation may instead
inspect the final document and assert agreement, but shall not emit a body twice.
The mechanism for step 2's override, and why it disturbs nothing else. The DTO's prompt
field is declared any. In branches 1, 3 and 4 it holds the prompt as a plain Go string, which
is what the export does today; only in branch 2 does it hold a *yaml.Node. This matters because
a field whose type changed would put every prompt on a new code path: probed across twelve prompt
classes — leading newline, bare newline, ordinary multi-line, trailing space, CR-only, NEL-only,
LS-only, C0 control, single line, and a single-line prompt reading true — an any-typed field
holding a string emits byte-identical output to a string-typed field in all twelve. The
override is therefore inert everywhere except the branch that needs it.
Tag: "!!str" is required on that node and is not decoration. A *yaml.Node scalar with Tag
unset is re-resolved by the encoder against its own value, which is exactly the mechanism AC-41
exploits for numbers and exactly the mechanism that would retype a prompt whose entire text is
true or null. AC-41's "leave Tag unset" rule is scoped to JSON numbers in trigger config
and applies nowhere else; see AC-8 for the same distinction on config strings.
Probed on all twelve classes: the forced double-quoted form round-trips byte-identical in every one, including the four leading-newline cases the default emission loses. The forced form is less readable than a block scalar, which is why AC-49 warns; it is not less faithful, which is why AC-49 is a fix rather than only a diagnostic.
Three things fall out of that table and each is load-bearing. All three chomping indicators —
|, |-, |+ — are LiteralStyle, so the test is a style check and never a prefix match on
the emitted text. !!binary is identifiable by its tag, which is what lets AC-47 take
precedence without re-testing the bytes. And a single-line prompt is Plain or SingleQuoted
rather than Literal, so "not a literal block scalar" is not by itself a degradation — a block
scalar is unreachable for a string with no newline, which is why AC-17's antecedent still
requires one.
Byte fidelity survives every degraded case, but degradation is not the only way to lose bytes. Probed: every non-literal value above, plus the BOM, C1-control and U+10FFFD cases, decodes back byte-identical — for those, degradation costs readability and never data.
An earlier revision of this spec generalized that into "AC-15 is never in tension with AC-17", and that claim was false. It was falsified by measurement, not by argument: a prompt beginning with a newline loses that newline, and does so while emitting as a literal block scalar, which is precisely the case AC-17 is defined not to warn about. The 13-case table this claim was drawn from contained no prompt that starts with a newline, so the generalization was made over a sample that excluded the counterexample.
The correction is structural rather than a patch to the sentence. Style and fidelity are two independent observables and the export checks both — style answers "will a human be able to read this diff" (AC-16, AC-17), fidelity answers "are these the same bytes" (AC-15, AC-49). A value can fail either without failing the other. Where the default emission would lose bytes, AC-49 requires the export to emit that prompt in a form probed to preserve them, so AC-15 holds as an outcome and not merely as a promise about the exporter's own restraint.
What causes degradation, non-normatively. No acceptance criterion depends on the following list and no test may be written against it; it exists so a human reading a warning has somewhere to start looking. As of v3.0.1 the usual causes are a trailing space, a space before a line break, a carriage return (a CRLF prompt), a control character, an emoji or other astral character, and the byte-order mark. This list is illustrative and is permitted to be incomplete — that is precisely the point of observing the emitted style instead of predicting it. If it drifts out of date, nothing breaks.
Two options are rejected:
- Normalizing on export (stripping trailing whitespace, folding CRLF, dropping astral
characters) would make the export an inexact copy. A later applier would then rewrite the stored
prompt on the first sync. An export that quietly changes the thing it exports is the same class
of defect as Office dropping
reports_to— different direction, same betrayal. - Emitting as-is and saying nothing leaves the user with an unreadable diff and no explanation.
The export therefore emits the prompt byte-faithfully and adds exactly one warning when the emitted form is not a literal block scalar (AC-17). Fidelity is preserved; the degradation is visible rather than mysterious.
Measured against the live corpus: 0 of 7 prompts degrade — every one serializes as a block
scalar today. All 7 lack a final newline and so use |-, which is equally readable and is still
LiteralStyle. The corpus is clean; an emoji pasted into a prompt is a likelier way to break it
than a trailing space is.
The encoder is pinned
yaml.v3's package-level Marshal defaults to a 4-space indent; yaml.Encoder.SetIndent
changes it. Office's writeYAMLFile uses the default and never pins it. Byte-determinism
(AC-9) requires the indent to be part of the contract rather than a library default that
a dependency bump can move, so the export pins 2-space indentation explicitly.
Data model
The exported document. Field names are the YAML keys.
version: 1
type: kandev_automations
automations:
- name: Daily Review — @kegmil/offline-first
enabled: false
max_concurrent_runs: 1
task_title_template: Daily Review — offline-first ({{trigger.timestamp}})
prompt: |
...
agent_profile:
agent_name: Claude Code
model: opus[1m]
mode: auto
executor_profile:
executor: exec-worktree
name: Worktree
workflow:
name: Kanban
step: In Progress
repositories:
- kegmil-offline-first
triggers:
- type: scheduled
enabled: true
config:
cron_expression: 0 9 * * *
timezone: Asia/Singapore
Included, always emitted: name, enabled, max_concurrent_runs, triggers
(possibly empty), and per trigger type, enabled, config (possibly empty).
Included, omitted when empty: description, prompt, task_title_template,
agent_profile, executor_profile, workflow, repositories, and the document-level
warnings (AC-20).
Excluded — secret: webhook_secret.
Excluded — runtime state: last_triggered_at, created_at, updated_at (automation);
last_evaluated_at, created_at, updated_at (trigger); everything in automation_runs.
Excluded — instance identity: id, workspace_id, automation_id, trigger.id.
Excluded — withdrawn or legacy columns: execution_mode (withdrawn; live values are
'' on six rows and 'run' on one, and no firing path reads it), automations.repository_id
(inert legacy column superseded by automation_repositories), and the derived
legacy_board_card field.
Field disposition
This table is the contract AC-22 tests against. Every field of Automation and
AutomationTrigger appears exactly once. exported names the YAML key it becomes;
excluded names why.
| Struct | Field | Disposition |
|---|---|---|
Automation |
Name |
exported → name |
Automation |
Description |
exported → description |
Automation |
Prompt |
exported → prompt |
Automation |
TaskTitleTemplate |
exported → task_title_template |
Automation |
Enabled |
exported → enabled |
Automation |
MaxConcurrentRuns |
exported → max_concurrent_runs |
Automation |
Triggers |
exported → triggers |
Automation |
WorkflowID |
exported → workflow.name (renamed; resolved to a descriptor) |
Automation |
WorkflowStepID |
exported → workflow.step (renamed; resolved to a descriptor) |
Automation |
AgentProfileID |
exported → agent_profile (renamed; resolved to a descriptor) |
Automation |
ExecutorProfileID |
exported → executor_profile (renamed; resolved to a descriptor) |
Automation |
RepositoryIDs |
exported → repositories (renamed; resolved to names) |
Automation |
WebhookSecret |
excluded — secret |
Automation |
ID |
excluded — instance identity |
Automation |
WorkspaceID |
excluded — instance identity |
Automation |
LastTriggeredAt |
excluded — runtime state |
Automation |
CreatedAt |
excluded — runtime state / fire anchor |
Automation |
UpdatedAt |
excluded — runtime state |
Automation |
LegacyBoardCard |
excluded — derived from a withdrawn column |
AutomationTrigger |
Type |
exported → type |
AutomationTrigger |
Enabled |
exported → enabled |
AutomationTrigger |
Config |
exported → config (the decoded form; see AC-11, AC-39, AC-41) |
AutomationTrigger |
ConfigJSON |
excluded — raw storage of Config, same value, not a second concept |
AutomationTrigger |
ID |
excluded — instance identity |
AutomationTrigger |
AutomationID |
excluded — instance identity |
AutomationTrigger |
LastEvaluatedAt |
excluded — runtime state / fire anchor |
AutomationTrigger |
CreatedAt |
excluded — runtime state / fire anchor |
AutomationTrigger |
UpdatedAt |
excluded — runtime state |
Two notes a builder needs. AutomationTrigger carries two fields for one concept —
Config json.RawMessage (tagged db:"-") and ConfigJSON string (tagged db:"config") —
and the DTO has a single config; the table settles which is which rather than leaving it
to a tag convention. And the columns excluded above that are not struct fields
(execution_mode, automations.repository_id) are deliberately absent from this table,
because reflection cannot see them; they are covered by AC-43 instead.
API surface
Two read-only endpoints, mirroring the shape Office's config export already established
(GET /workspaces/:wsId/config/export and .../export/zip):
| Method | Path | Response |
|---|---|---|
GET |
/api/v1/workspaces/:wsId/automations/export |
200, Content-Type: application/yaml, body is the single YAML document |
GET |
/api/v1/workspaces/:wsId/automations/export/zip |
200, Content-Type: application/zip, Content-Disposition: attachment; filename=kandev-automations.zip |
REST rather than a WebSocket action: every other automation operation is a WS action because
it is interactive state, but an export is a bulk byte stream a client retrieves at a URL — a plain
HTTP GET a fetch can read a status and a Blob from, and that a human or a script can also
curl directly. Content-Disposition is set for that direct-access case; it is not the
mechanism the in-app control uses, which is fetch + Blob per AC-37. Office set the REST
precedent for the same job, though not the frontend one.
Acceptance criteria
Export content
AC-1 — When a user requests the export for a workspace, the system shall return a
YAML document whose version is 1 and whose type is kandev_automations.
AC-2 — When a user requests the export for a workspace, the system shall include exactly the automations belonging to that workspace, and no automation belonging to any other workspace.
AC-3 — When an automation is exported, the system shall emit name, enabled,
max_concurrent_runs, and triggers unconditionally, including when triggers is empty.
AC-4 — When an automation is exported, the system shall omit description, prompt,
task_title_template, agent_profile, executor_profile, workflow, and repositories
when the underlying value is empty, and emit them otherwise.
AC-5 — When a trigger is exported, the system shall emit its type, its enabled
flag, and its config, and shall emit config as a mapping even when the stored config
is {}.
AC-39 — If a trigger's stored config is not valid UTF-8, is not valid JSON, or is valid
JSON that is not an object (null, an array, a string, a number, or a boolean), then the system
shall emit config as an empty mapping, add a warning naming the automation, the trigger type, and
which of the three conditions occurred, and still export the trigger and its automation.
Export shall not fail.
The UTF-8 check shall be made on the raw stored bytes, before decoding, and takes precedence over the other two conditions, so exactly one warning is emitted for such a trigger.
(automation_triggers.config is raw TEXT; nothing enforces that it is UTF-8 or that it decodes to
an object. Probed: null, [1,2], "str", 42 and true all decode without error and none is a
map[string]any, so a type check is required, not just an error check.
The UTF-8 condition is separate and is the subtle one, because the failure is silent rather than
loud: encoding/json does not reject invalid UTF-8 in a string value — it substitutes U+FFFD
and returns no error. Without this clause a config string containing an invalid byte decodes
"successfully", passes the JSON check and the object check, and is emitted with its bytes changed,
while the export reports nothing. That is a direct contradiction of AC-34, which states the export
reports what is in the database rather than normalizing it, and it is the same class of silent
alteration the spec already refuses for prompts (AC-47) and for numbers (AC-41, character-for-
character). Checking the raw bytes first is what keeps the three conditions to one warning apiece
and keeps this one detectable at all — after decoding, the evidence is gone. Emitting an empty
mapping rather than the corrupted values is deliberate: a partially-mangled config in a committed
artifact is worse than an absent one, because a human reviewing the diff cannot tell which values
are real.)
Redaction and exclusion
AC-6 — The exported YAML shall not contain the automation's webhook_secret under
any key, in either output form. (Test with a non-empty secret containing a
recognizable sentinel and assert the sentinel is absent from the serialized bytes, not
merely that a named field is unset — the leak this guards against is a mis-keyed field,
not an unset one.)
AC-7 — The exported YAML shall not contain id, workspace_id, automation_id,
created_at, updated_at, last_triggered_at, last_evaluated_at, execution_mode,
legacy_board_card, or automations.repository_id, for any automation or trigger.
Determinism
AC-8 — The system shall order the export deterministically:
- automations by
nameascending, tiebroken byautomations.idascending; - triggers within an automation by
automation_triggers.typeascending, tiebroken byautomation_triggers.idascending; - repositories by
automation_repositories.positionascending, tiebroken byautomation_repositories.repository_idascending; - keys within a trigger
configmapping by key ascending, at every nesting depth.
Sort order is byte-wise ascending on the UTF-8 encoding of the key.
The config mapping shall be emitted as an explicitly ordered *yaml.Node of kind
MappingNode whose key/value pairs the exporter has already sorted byte-wise, at every nesting
depth. It shall not be emitted by handing a Go map[string]any to yaml.v3 and relying on the
marshaller to sort. Sequence order is the stored JSON array order and is never sorted.
Every JSON string in config — every key, and every value whose JSON type is string — shall be
emitted as a *yaml.Node scalar carrying an explicit Tag: "!!str", at every nesting depth.
This is the exact opposite of AC-41's rule for numbers, and the two are stated together here so
neither is applied to the other's type.
Every JSON type has exactly one node form, and the table below is the whole rule. Once AC-8
requires an explicit MappingNode, every descendant is builder-constructed, so leaving any type
unstated forces a guess:
| JSON type | Go type after UseNumber decode |
Node form |
|---|---|---|
| object | map[string]any |
MappingNode, keys sorted byte-wise per this AC |
| array | []any |
SequenceNode, stored order preserved, never sorted |
| string (and every key) | string |
ScalarNode, Tag: "!!str" |
| number | json.Number |
ScalarNode, Tag unset, Value the stored lexeme (AC-41) |
true / false |
bool |
ScalarNode, Tag: "!!bool", Value true or false |
null |
nil |
ScalarNode, Tag: "!!null", Value null |
(The Go column is measured, not assumed: probed against
{"s":"true","n":12,"b":true,"z":null,"arr":[...],"o":{...}} decoded with UseNumber, the six
types land as string, json.Number, bool, nil, []any and map[string]any respectively.
The !!bool and !!null rows are the only two where the explicit tag changes nothing — probed,
a bool or null node emits and re-parses identically with the tag set or unset, because their
lexemes resolve to themselves. They are stated anyway so the table is total: a builder who finds
four of six types specified reasonably infers the other two are someone else's problem, and the
one type where that inference is catastrophic is string.)
(Pinned because the natural implementation corrupts data silently and the spec's own round-trip
test does not catch it. A *yaml.Node container's children must themselves be *yaml.Nodes, so
once AC-8 requires an explicit MappingNode every scalar is builder-constructed, strings
included — and AC-41 is otherwise the only node-construction rule this spec states. Applied
uniformly, its Tag-unset instruction hands yaml.v3 a bare lexeme to re-resolve. Probed:
stored JSON string emitted re-parsed as with Tag: "!!str"
"true" -> true -> !!bool -> "true" (!!str)
"null" -> null -> !!null -> "null" (!!str)
"1.0" -> 1.0 -> !!float -> "1.0" (!!str)
"0755" -> 0755 -> !!int -> "0755" (!!str)
"12" -> 12 -> !!int -> "12" (!!str)
"~" -> ~ -> !!null -> "~" (!!str)
"yes" / "on" / "no" / "ordinary" already !!str, and stay unquoted with the tag set
So a trigger config of {"mode":"true"} exports as mode: true and a later applier reads a
boolean. Tag: "!!str" fixes every row and over-quotes none: yaml.v3 quotes only the values whose
bare form would resolve to another type, and YAML 1.2's core schema does not treat yes/on/no
as booleans, so those stay bare. Numbers keep AC-41's Tag-unset treatment for the separate
reason given there — an assigned numeric tag gets printed whenever the encoder's own resolution
disagrees. The rule is therefore per-JSON-type, never uniform.
AC-23 is the test that ought to catch this and, as originally written, could not: it compared
config scalars by lexeme, and the re-parsed true node's lexeme is still true. That gap is
closed in AC-23 itself.)
(Pinned because the natural implementation silently violates the clause above it. yaml.v3 sorts
map keys with its own comparator (sorter.go), which is digit-aware and letter-aware rather than
byte-wise. Probed: yaml emits v1, v2, v10 where byte-wise is v1, v10, v2; _beta, Alpha where
byte-wise is Alpha, _beta; step1, step2, step10 where byte-wise is step1, step10, step2.
AC-11 guarantees an unknown key survives verbatim, so digit-bearing and punctuation-leading keys
are squarely in scope, and a test fixture using only lowercase-letter keys passes against a
non-compliant export. The same node-based container is what AC-41's scalars are placed into, so
this costs nothing extra.)
AC-40 — The system shall emit the document's top-level keys in the fixed order
version, type, automations, warnings, and each automation's keys in the fixed order
name, description, enabled, max_concurrent_runs, task_title_template, prompt,
agent_profile, executor_profile, workflow, repositories, triggers, and each
trigger's keys in the fixed order type, enabled, config. Keys omitted under AC-4 are
skipped without disturbing the order of the rest.
(Every other ordering in this spec is pinned to a named column, but key order within a mapping was not, and AC-9 makes it part of the artifact's bytes. Declaring it here means the determinism contract does not rest on the marshaller emitting DTO struct fields in declaration order — true of yaml.v3 today, but an undeclared dependency on a library detail is exactly what AC-12 already refuses to accept for indentation.)
AC-9 — When the same workspace is exported twice with no intervening database change, the system shall produce byte-identical output both times. This binds both output forms: the single YAML document, and the complete bytes of the zip archive.
AC-10 — When two automations differ only in the whitespace of their stored trigger
config JSON, the system shall produce identical config YAML for both. (The live store
contains both a compact and a space-separated serialization of the scheduled trigger
config.)
AC-11 — When a trigger's stored config contains a key the exporter has no typed
field for, the system shall emit that key and its value in the exported config.
AC-41 — When a trigger's stored config contains a JSON number at any nesting depth,
the system shall emit it as an unquoted YAML number whose characters are exactly the
characters of the number as stored in the JSON, with no explicit tag. It shall not reformat,
round, re-base, add or remove an exponent, add or remove a trailing .0, or emit the number as a
quoted string. A stored 1000000000 emits as 1000000000; a stored 1e9 emits as 1e9; a
stored 1.0 emits as 1.0; a stored 9223372036854775808 emits as 9223372036854775808; a
stored 18446744073709551616 emits as 18446744073709551616.
This AC's Tag-unset rule applies to JSON numbers and to nothing else. JSON strings in the
same mapping take the opposite treatment — an explicit Tag: "!!str" — under AC-8. Applying the
rule below to a string retypes it; applying AC-8's rule to a number prints an explicit tag. The
two rules are per-JSON-type by construction, and a builder who unifies them breaks one or the
other.
(Mechanism, pinned because four implementations were probed and three corrupt or annotate the
output — see ## Decisions › "Numbers are copied, not converted". Decode with
json.Decoder.UseNumber(), then emit each json.Number as a *yaml.Node{Kind: ScalarNode, Value: n.String()} with Tag left unset. Two details are load-bearing and were each probed:
the node must be a pointer — a bare yaml.Node value inside a generic container panics with
interface conversion: interface {} is *interface {}, not *yaml.Node — and the Tag must not be
assigned. Setting Tag to !!int/!!float makes the encoder compare it against yaml.v3's own
re-resolution of the lexeme and print the tag explicitly whenever they disagree, which happens for
every integer outside [-2^63, 2^64) and every exponent outside float64 range: !!int 18446744073709551616, !!float 1e400. Leaving Tag unset skips that comparison and emits the
lexeme plain in every case. Do not convert through int64 or float64: that path branches on
lexical form while this AC is framed by characters.)
AC-12 — The system shall serialize with an explicitly configured 2-space indent rather than relying on the marshaller's default.
AC-13 — When two exports of the same workspace run concurrently and no mutation of
that workspace's automations commits between the start of the first and the start of the
second — start as ## Definitions fixes it, the moment each export's read transaction
establishes its snapshot — each shall produce byte-identical output to the other. (The qualifier is
load-bearing and matches AC-9's. Without it this AC contradicts AC-30: AC-30 gives each
export the snapshot open at its own start and explicitly permits a concurrent mutation, so
a commit landing between the two snapshots makes differing output required by AC-30 and
forbidden by AC-13. What AC-13 actually guards is that concurrency itself introduces no
nondeterminism — no map-iteration order, no shared buffer, no racing sort.)
AC-14 — The export shall be free of side effects, so a client may retry a failed or
interrupted request without consequence. (It is a GET; this records that no counter,
timestamp, or run row is touched — see ## Persistence guarantees.)
Prompt fidelity
AC-15 — When an automation's prompt is exported, the system shall emit it byte-for-byte, applying no trailing-whitespace stripping, no line-ending conversion, and no newline insertion or removal. This binds the emitted document, not merely the exporter's own restraint: for every prompt that is valid UTF-8, parsing the exported document back shall yield the stored prompt exactly. Where the marshaller's default choice would not satisfy this, AC-49 governs. Where the prompt is not valid UTF-8, AC-47 governs and fidelity is established by base64-decoding the emitted value.
(The second sentence was added because the first, alone, was satisfied by an export that lost data. "The system applies no conversion" is a claim about the exporter; "the bytes survive" is a claim about the artifact, and only the second is what a human relying on this diff needs. yaml.v3 drops a prompt's leading newline while emitting a flawless block scalar, so an exporter that did nothing wrong still produced a document that failed to round-trip. Stating the outcome makes the AC testable against the artifact rather than against the implementation's intentions.)
AC-16 — When a prompt contains at least one newline and is valid UTF-8, the system shall emit
it through the pinned marshaller without selecting or overriding the scalar style — except
where AC-49 requires an override to preserve the prompt's bytes — and shall add no warning
when the emitted scalar is a literal block scalar. All three chomping indicators — |, |-,
|+ — are literal block scalars and satisfy this AC equally. "Contains at least one newline" is
## Definitions' newline.
(The exception is narrow and its boundary is observable, not a matter of judgement: it applies only on AC-49's branch, which is entered only when the marshaller's own output has already been shown to differ from the stored prompt. This AC's purpose is unchanged — the export does not second-guess the marshaller's readability decisions, and does not cry wolf when a block scalar was produced. What it never meant to say is that the export must accept a silently altered prompt in order to keep its hands off the style, which is what the unqualified wording required.)
(This AC no longer mandates a block scalar, and that change is deliberate. Whether a block scalar
is producible is the marshaller's decision given the bytes, not the exporter's; three earlier
revisions mandated the outcome and were each falsified by an input the spec's condition list did
not name — see ## Decisions › "Prompts are emitted faithfully". What the export can promise, and
what this AC now states, is that it does nothing to prevent one and does not cry wolf when one was
produced. All 7 live prompts satisfy this; the readable diff is the feature.)
AC-17 — If a prompt contains at least one newline (as ## Definitions defines it), is valid
UTF-8, the emitted scalar is not a literal block scalar, and AC-49 has not been triggered
for that prompt, then the system shall add exactly one warning naming the automation,
carrying the message AC-42's vocabulary table defines for this condition. Export shall
not fail, and the prompt shall not be modified to make it qualify. AC-42 owns the exact message
bytes; this AC owns when the message is emitted, and the two are stated in one place each so they
cannot drift.
(The AC-49 clause keeps "exactly one" true. AC-49's branch deliberately emits a double-quoted
scalar, which is not a literal block scalar, so without the exclusion both ACs would fire on the
same prompt and the automation would carry two prompt warnings describing one problem. AC-49's
message already tells the human the prompt is not in block form; the ordered classification in
## Decisions is what makes the exclusion mechanical rather than a judgement call.)
The condition shall be determined by inspecting the style of the emitted scalar — not by testing the prompt's characters against any list of degradation conditions. A conforming implementation observes what the marshaller produced; it does not predict it.
(One warning with one fixed reason replaces the previous three-condition table and its
one-warning-per-condition rule. The table was the defect: it was rewritten in three consecutive
review rounds and found incomplete each time, most recently because U+2028 is simultaneously a
line break to is_break and printable to is_printable, so it satisfied no listed condition
while still costing the block scalar. A fixed single reason cannot drift, keeps AC-42's vocabulary
finite, and keeps AC-9 deterministic. The diagnostic value given up is real but small: the human
reading the warning knows the prompt is unreadable in the diff and can see the prompt, and
## Decisions carries a non-normative list of usual causes that no test may depend on.)
(Testability, stated because the obvious test re-implements the bug: a test for this AC shall
parse the exported document, locate the automation's prompt node, and assert the biconditional
— a warning with this reason is present if and only if that node's style is not literal, given
the prompt has a newline and is valid UTF-8. Asserting instead that a particular input character
produces a warning rebuilds the enumeration this AC exists to delete, and will drift the same way.)
AC-46 — When a non-empty prompt contains no newline at all (as ## Definitions defines
it) and is valid UTF-8, the system shall emit it in whatever single-line form YAML requires —
plain, single-quoted, or double-quoted as the value demands — and shall not add a warning.
(Probed against yaml.v3: "Do the thing" emits as the plain scalar prompt: Do the thing, and
"Do the thing " as prompt: 'Do the thing '. Neither is a literal block scalar, and neither is a
degradation: a block scalar is unreachable for a string with no newline, so reporting one would
misdescribe a structural impossibility as a fault. This is why AC-17's antecedent requires a
newline — not, as an earlier draft of this rationale claimed, to stop AC-17 firing on every
single-line prompt in the store; AC-17's newline clause already prevents that on its own.
The non-empty qualifier is load-bearing in the other direction: the empty string has no newline
and is valid UTF-8, so without it this AC would require emitting prompt: "" for a prompt AC-4
requires be omitted entirely, and the two would mandate different documents. AC-4 wins — an empty
prompt is omitted and no prompt AC applies to it. The valid-UTF-8 qualifier is load-bearing too:
without it a single-line invalid-UTF-8 prompt would satisfy this AC and AC-47 simultaneously and
they mandate different bytes. AC-47 wins, and says so.)
AC-47 — When a prompt is not valid UTF-8, the system shall emit it as the !!binary value
yaml.v3 produces, add a warning naming the automation and carrying the invalid UTF-8 message
AC-42's vocabulary table defines for this condition, and still return 200. This applies whether or not the prompt contains a newline; it takes precedence
over both AC-46 and AC-17; and invalid UTF-8 shall be the only reason emitted for that
prompt — AC-17's style check is not additionally applied, because yaml.v3 has already left the
text representation entirely. This condition shall not be treated as a serialization failure
under AC-36.
(Probed: "line one\nbad\xff\xfebyte\n" does not error — yaml.v3 emits
prompt: !!binary bGluZSBvbmUKYmFk//5ieXRlCg== — and the no-newline form
"bad\xff\xfebyte no newline" emits prompt: !!binary YmFk//5ieXRlIG5vIG5ld2xpbmU=. The emitted
node carries tag !!binary, which is how this case is told apart from AC-17's without
re-examining the bytes; utf8.ValidString on the source is equivalent and either is conforming.
That second case is why the precedence is stated outright: !!binary is none of the three
single-line forms AC-46 permits, so without an explicit winner the two ACs mandate different bytes
for one input. The prompt column is SQLite TEXT read into a plain Go string and nothing enforces
UTF-8 validity, so a row written by direct SQL or an external tool is representable. Byte fidelity
under AC-15 is preserved by the base64 — probed, it decodes back byte-identical; what is lost is
readability, which is exactly the AC-17 degradation class. Failing the whole workspace's export
over one bad row would be the wrong trade.)
AC-49 — If a prompt is valid UTF-8 and the scalar the pinned marshaller emits for it does
not decode back to the stored prompt byte-for-byte, then the system shall emit that prompt in
the final document as a double-quoted scalar carrying an explicit !!str tag, shall verify that
this form decodes back byte-for-byte, and shall add exactly one warning naming the automation
and carrying the prompt re-quoted to preserve bytes message AC-42's vocabulary table defines.
Export shall not fail, and the stored prompt shall not be altered to make the default form work.
Both the default form and the double-quoted form are checked in the probe phase, on throwaway
single-prompt marshals, before the final document is built. ## Decisions' prohibition on
marshalling, inspecting, amending and re-marshalling binds the final document body only; the
per-prompt probes are not that body and a second probe of the re-quoted form is required rather
than merely permitted.
If the double-quoted form also fails to decode back byte-for-byte, that is a serialization
failure under AC-36 and the export shall return 500 with no partial document. No prompt whose
bytes could not be preserved is ever emitted.
(This AC exists because style and fidelity are independent, and four review rounds checked only
style. Probed: "\nhello" exports and decodes back as "hello", "\n" as "", "\n\nhello" as
"\nhello", "\n\n" as "\n" — and in every one of those the emitted scalar is a literal
block scalar, so AC-16 is satisfied and AC-17's antecedent is false. Nothing warned, nothing
failed, and the committed artifact silently differed from the database. A prompt pasted with a
leading blank line is ordinary user behaviour, so this is not an edge case reachable only by
direct SQL.
The remedy preserves rather than merely reports, because the card requires the export be "enough
to recreate the automation by hand" and a lost leading newline defeats that no matter how loudly
it is announced. Probed across twelve prompt classes, the double-quoted form round-trips
byte-identical in all twelve, including the four the default emission loses; the !!str tag
is required so a prompt whose entire text is true or null is not re-resolved to another type.
The 500 clause is a backstop that no probed input reaches. It is stated because the
alternative to failing, in a case where both forms lose bytes, is committing corrupted data to a
git repository — and the whole feature exists to stop that. It is deliberately narrower than
AC-47, which returns 200: an invalid-UTF-8 prompt is losslessly representable as !!binary, so
there is nothing to fail about.
Testability: a test for this AC shall parse the exported document, decode the automation's
prompt node, and assert equality with the stored prompt, over a fixture corpus that includes
"\nhello", "\n", "\n\nhello" and "\n\n" alongside prompts that round-trip under the
default emission. Asserting only that a warning appears for a named input would re-create the
enumeration problem AC-17 exists to avoid; the observable is the round trip itself.)
Portable references
AC-18 — When an automation references an agent profile, an executor profile, a
workflow and step, or repositories, the system shall emit the portable descriptors defined
in ## Data model and shall emit no UUID for any of them.
AC-19 — If a referenced agent profile, executor profile, workflow, workflow step, or repository is absent, then the system shall omit that reference from the automation, emit a warning naming the automation and the unresolved reference, and still export the automation. Export shall not fail because a reference is dangling. Partial resolution is resolved as follows, and each case emits its own warning:
- Workflow resolves, step does not: emit
workflowwithnameonly and nostepkey. The workflow reference is still true and still useful to a human recreating the automation. - Workflow does not resolve: omit the whole
workflowdescriptor, including any step. A step name without its workflow names nothing. - Repositories, some members unresolved: drop only the unresolved members and keep the resolved ones in their AC-8 order. If every member is unresolved the list becomes empty and is then omitted under AC-4.
- Agent or executor profile unresolved: omit that whole descriptor. Neither is meaningful partially populated.
This AC applies only to a reference that is actually made. A foreign-key column holding the
empty string references nothing, is not "absent", and shall produce no warning. Specifically:
when workflow_id is non-empty and workflow_step_id is empty, the system shall emit
workflow with name only and no step key, and shall add no warning — the same document shape
as the unresolved-step case above, but silent. When agent_profile_id, executor_profile_id or
workflow_id is empty, AC-4 omits that descriptor and no warning is emitted. An empty
repository_ids list is likewise AC-4's business, not this AC's.
(Stated because Automation.WorkflowStepID is a plain string and
internal/automation/models.go:79 documents the pair outright: "WorkflowID / WorkflowStepID are
optional: no automation run is placed…". A workflow without a step is therefore an ordinary,
service-supported configuration, not a damaged row. AC-4's key list is flat and names workflow
as a whole, so it cannot express the nested step, and this AC's own list covered only a step
that was referenced and could not be found — leaving the common case matching neither. A builder
resolving that silence toward "warn" would emit unresolved workflow step for every correctly
configured step-less automation, filling warnings with false positives in an artifact whose
entire value is a quiet, reviewable diff. The distinction is between "you asked for something that
is not there", which is worth a human's attention, and "you asked for nothing", which is not.)
"Absent" and "the lookup failed" are different outcomes and this AC covers only the first.
Every descriptor lookup shall report three outcomes distinguishably — resolved, absent, and
failed. Absent takes the path above. Failed does not: an error from a descriptor lookup is
an infrastructure failure and returns 500 under AC-45, with no partial document and no warning.
(Stated because the spec applies exactly this discipline to the workspace authorizer and the
workspace lookup — AC-44 steps 1 and 2, both classifying on repoerrors.ErrWorkspaceNotFound —
and applied nothing equivalent one level down, leaving "cannot be resolved" to cover both. Without
the distinction a database error while reading agent_profiles is silently reported as a dangling
reference: the export returns 200, the committed artifact loses a reference that was never
actually dangling, and a later applier recreates the automation without it. The precedent a builder
would reach for makes this concrete rather than theoretical —
buildAgentProfileResolver (backendapp/services.go) is written
if err != nil || profile == nil { return nil }, collapsing both outcomes into one nil and calling
context.Background() besides. It must not be reused for this export, and the lookups defined
in AC-29 exist partly to replace it. A two-valued (value, ok) signature cannot express this AC and
is non-conforming; (value, found bool, err error), or a (value, error) whose absence is a
distinguishable sentinel the caller matches with errors.Is, both can. Which of the two is the
builder's choice — what this AC fixes is that absence and failure must be separable by the caller
without inspecting an error string.)
AC-20 — The system shall carry warnings inside the exported artifact itself, not in a
transport-level sidecar: as a top-level warnings sequence in the single-document form,
and as .kandev/automations/WARNINGS.txt in the zip form. Where there are no warnings the
warnings key shall be omitted and the file shall be absent.
(The body is application/yaml, so there is no envelope to put a sidecar in, and a
response header cannot carry multi-line text. Keeping warnings in the artifact also means
a dangling reference shows up in the committed diff, which is where the reviewer will
actually see it.)
AC-21 — The system shall order warnings by automation name ascending, tiebroken by
automations.id ascending, then by the warning text ascending, so that AC-9 holds when
warnings are present.
AC-42 — Each warning shall be a single-line YAML string scalar — a string, never a
mapping — of the form <automation name>: <message>, containing no newline as ## Definitions
defines it. The warnings value
is a sequence of those strings. WARNINGS.txt shall contain the same strings in the same AC-21
order, one per line, UTF-8, each line terminated by \n including the last.
The emitted style is yaml.v3's choice and is not a plain scalar. Every warning contains
": ", which makes a plain scalar impossible, so yaml.v3 single-quotes it. Probed:
- 'Daily Sync: unresolved agent profile', and an entirely benign name is quoted identically
(- 'plain name: unresolved workflow'). A test for this AC shall assert on the decoded string
value and on the node being a scalar rather than a mapping; it shall not assert a plain
style, which no compliant export can produce for any warning this spec defines.
Name and type rendering. automations.name is unconstrained TEXT, and
automation_triggers.type is TEXT NOT NULL with no CHECK constraint
(internal/automation/store.go), so both are unconstrained in practice regardless of what the
service accepts. Before interpolation, both the automation name and the trigger type shall be
rendered by applying all four of the following, and nothing else:
- U+000A becomes
\nand U+000D becomes\r. - Every other character in U+0000–U+001F, U+007F, and U+0085 becomes
\xfollowed by exactly two lowercase hex digits. - U+2028 and U+2029 become
\ufollowed by exactly four lowercase hex digits — that is, the six ASCII characters\u2028and\u2029respectively. - Every byte that is not part of a well-formed UTF-8 sequence becomes
\xfollowed by exactly two lowercase hex digits. After this step the rendered value is valid UTF-8 by construction.
No other character is altered.
The four rules operate on disjoint inputs, so the order in which they are applied cannot change
the result: rules 1 and 2 touch only characters below U+0080 plus U+0085, rule 3 touches exactly
two characters, and rule 4 touches only bytes that are part of no well-formed sequence — which are
all at or above 0x80 and, U+0085 being well-formed C2 85, never the characters rule 2 names.
(Rules 2 and 3 gained U+0085, U+2028 and U+2029 when ## Definitions fixed newline as YAML's
five-character line-break set. Before that, this AC required each warning to contain "no newline"
while its rendering rules preserved three characters that are newlines under that definition, and
closed with "No other character is altered" — so the AC forbade the only edit that could satisfy
it. The failure was not theoretical in either output form. Probed: a name carrying U+2028 emits as
- 'Daily<U+2028> Sync: unresolved agent profile' — the raw character survives into the file and
yaml.v3 folds the line around it — and the same raw bytes are written to WARNINGS.txt, where a
reader that honours the YAML and Unicode line-break set sees two lines where this AC promised one.
Escaping them keeps one warning on one line under every reading.
Rules 1 and 2 otherwise exist because a stored newline in either value makes "containing no
newline" and "of the form <automation name>: <message>" jointly unsatisfiable. Rule 4 exists
because both
columns are TEXT and SQLite does not validate encoding, so a row written by direct SQL can carry
an invalid byte — and without it that byte reaches the warning string, at which point yaml.v3
emits the whole warning as !!binary by exactly the mechanism AC-47 documents for prompts.
That would break this AC twice over: a base64 blob is neither a string scalar of the stated form
nor a line of the UTF-8 WARNINGS.txt promised above. Escaping the bytes keeps the warning
readable, keeps both output forms well-formed, and keeps the byte sequence recoverable by a human
reading the diff. Note the asymmetry with AC-47, which is deliberate: an invalid-UTF-8 prompt
is preserved exactly and allowed to become !!binary, because the prompt is the payload and
fidelity outranks readability; an invalid-UTF-8 name inside a warning is escaped, because the
warning is diagnostic text whose whole job is to stay readable.)
De-duplication is scoped to the entity the message describes, per the Scope column of the
vocabulary table below. Two identical messages for the same automation are emitted once; two
automations that produce byte-identical warning strings each keep their own. The AC-39 messages
describe a trigger, so their scope is the trigger: two triggers on one automation that
produce the same string each keep their own line.
(The scope of the dedup is the whole point, at both levels. Automation names are not unique —
neither automations nor Service.CreateAutomation enforces it — so two automations named "Daily
Sync" that both have an unresolved agent profile produce the same string, and a global dedup would
silently drop the second, contradicting AC-19's promise that each unresolved reference emits its
own warning. The identical argument applies one level down: the AC-39 messages name a trigger
type, never a trigger identity, and automation_triggers has no UNIQUE constraint on
(automation_id, type) — AC-8 tiebreaks triggers by automation_triggers.id precisely because two
triggers can share a type. An automation-scoped dedup would therefore collapse two malformed
same-type triggers into one line and report one problem where there are two, which is the exact
silent loss this feature exists to prevent. Per-trigger scope costs a repeated line and keeps the
count. AC-9 is unaffected: identical strings sort identically under AC-21, so the bytes stay
deterministic. The prompt messages are automation-scoped and AC-17 now emits at most one of them
per prompt, so for those the dedup is a no-op.)
Message vocabulary. The message half is fixed text, because AC-21 sorts warnings by it and AC-9 makes it part of the artifact's bytes; two reasonable wordings would otherwise produce two different committed files. The messages are exactly:
| Condition | Scope | Message |
|---|---|---|
| Agent profile unresolved (AC-19) | automation | unresolved agent profile |
| Executor profile unresolved (AC-19) | automation | unresolved executor profile |
| Workflow unresolved (AC-19) | automation | unresolved workflow |
| Workflow resolved, step unresolved (AC-19) | automation | unresolved workflow step |
| Repository unresolved (AC-19) | automation | unresolved repository at position <position> |
| Trigger config not valid UTF-8 (AC-39) | trigger | trigger <type>: config is not valid UTF-8 |
| Trigger config not valid JSON (AC-39) | trigger | trigger <type>: config is not valid JSON |
| Trigger config valid JSON but not an object (AC-39) | trigger | trigger <type>: config is not a JSON object |
| Prompt not emitted as a block scalar (AC-17) | automation | prompt not emitted as a block scalar |
| Prompt not valid UTF-8 (AC-47) | automation | prompt not emitted as a block scalar: invalid UTF-8 |
| Prompt re-quoted to preserve bytes (AC-49) | automation | prompt re-quoted to preserve bytes |
(The repository message names a position rather than an id because AC-18 keeps UUIDs out of the
artifact and the unresolved repository has no name to give; automation_repositories.position is
the stable handle that survives. The Scope column is what the dedup rule above keys on. The three
prompt-degradation rows this table previously carried — one each for trailing space, carriage
return and non-printable character — are replaced by the single row above: AC-17 no longer
classifies why a block scalar was lost, only that it was, so there is exactly one message
and it cannot drift out of step with the library. invalid UTF-8 survives as its own row because
AC-47 detects it from the emitted tag rather than from any character list, so it carries no drift
risk and is worth telling a human apart from an ordinary degradation.)
Round-trip completeness
AC-22 — The system shall hold a Go disposition table — a literal in the test package listing
every field of Automation and AutomationTrigger with its disposition — and a test that
enumerates both structs by reflection and asserts each field is accounted for by exactly one entry
in that table. Adding a field to either domain struct without adding it to the Go table shall fail
the test. The markdown table in ## Data model › "Field disposition" is documentation of the
Go table and must be updated alongside it; the Go table is the test oracle.
(This is the anti-reports_to guard. Office's config export declares reports_to in its DTO,
emits it, and drops it on import; nothing failed. A field that is neither exported nor consciously
excluded is the defect. The oracle is a Go literal rather than the markdown table parsed at
runtime because a test that reads a docs path is fragile in a way that gets it deleted; the cost
is that markdown drift is a documentation bug rather than a test failure, which is the right
trade for a guard whose job is preventing silent data loss. The match is against an explicit
disposition list, not against DTO field names, because five domain fields are deliberately renamed
on the way out and one concept is carried by two struct fields — a name-identity test cannot
express either, and would fail on first run for bookkeeping reasons, which is the surest way to
get the guard loosened.)
(Division of labour, stated because it is easy to assume this AC does more than it does: AC-22 proves every field is consciously classified. It does not prove the DTO actually marshals an exported field to its key — that is AC-23 — and it cannot see a column that never becomes a struct field — that is AC-43. All three are required.)
AC-43 — The system shall hold a test over a fixture in which every excluded column
(webhook_secret, last_evaluated_at, last_triggered_at, created_at, updated_at,
workspace_id, automation_id, id, execution_mode, repository_id, legacy_board_card)
holds a distinctive sentinel value where its type permits one, and shall assert both of the
following against both output forms:
- No sentinel value appears anywhere in the serialized bytes.
- No key resolves to an excluded column, checked structurally: parse the output, walk every
mapping key at every depth outside the
configsubtrees, normalize each key by lowercasing it and removing_and-, and assert none equals the same normalization of any excluded column name. Every name in that list is written bare, with no table qualifier: the normalization strips only_and-, so a qualifiedautomations.repository_idwould normalize toautomations.repositoryid, retain its., and be unable to match any YAML key this export can produce — leaving the one column half 2 exists for with no coverage at all while the check appeared to pass.
(Both halves are load-bearing and neither subsumes the other. Half 2 exists because the failure
this backstops is yaml.v3 mangling key names: marshalling the domain struct emits
webhooksecret and lasttriggered, so a fire-anchor leak would appear under lastevaluatedat
and a test grepping for the literal string last_evaluated_at would pass while leaking. Half 1
exists because a leak can also arrive under a key nobody predicted, where only the value gives it
away. The config subtrees are excluded from half 2 because AC-11 requires an unknown config key
to survive verbatim, and a user's config key legitimately named webhook_secret is preserved
data, not a leak; the sentinel check in half 1 still covers the values. Normalizing keys before
comparison is what makes half 2 robust to the mangling instead of blind to it, and matching on
normalized keys rather than raw substrings is what stops an ordinary prompt or description
containing the words "created at" from failing a compliant export.)
(legacy_board_card is on the list for half 2 only, and the "where its type permits one" clause
above is what exempts it from half 1. It is a Go bool derived from the withdrawn execution_mode
column, and a bool has no distinctive sentinel value — true appears legitimately throughout the
document as enabled. Half 2 still applies in full and is the half that matters here: AC-7 forbids
this key under any name, and marshalling the domain struct would emit it as legacyboardcard,
which normalizes to exactly what half 2 compares against. Omitting it from this list entirely, as
an earlier revision did, left the only structural guard in the spec blind to a column AC-7 names
explicitly — AC-22 proves it was consciously classified, which its own parenthetical is careful to
say is not the same thing.)
(AC-22 cannot cover all of these. automations.repository_id and execution_mode are not
fields on the Automation struct at all — repository_id is touched only by raw SQL in the
legacy backfill path, and execution_mode reaches Go solely as the derived legacy_board_card
alias. A reflection pass over struct fields structurally cannot enumerate a column that never
becomes a field, so the two exclusions this spec singles out by name would have had zero coverage
from the mechanism named as their enforcer, and any future SQL-only column would slip the same
way.)
AC-23 — The system shall hold a test that exports a fully-populated automation — every
optional field set, at least two triggers, at least two repositories — parses the exported
document back with yaml.Unmarshal into a generic yaml.Node tree, and asserts that for every
row in the AC-22 disposition table marked exported, the value reachable at its YAML key equals
the expected value declared literally in the test. Absence of a key whose source value was
non-empty shall fail the test.
The expected value is defined per row, because six rows are transforms and equality with the raw source field is false by design for all six:
-
Untransformed rows (
name,description,prompt,task_title_template,enabled,max_concurrent_runs, and each trigger'stypeandenabled): the expected value is the source field itself. -
The five reference rows (
WorkflowID,WorkflowStepID,AgentProfileID,ExecutorProfileID,RepositoryIDs): the expected value is the descriptor the fixture's referenced rows imply, written into the test as a literal — e.g. the agent profile fixture's{agent_name, model, mode}triple spelled out, not read back from the profile row and not produced by calling any exporter helper. AC-18 forbids emitting the UUID, so comparing against the raw foreign key would fail against a correct implementation. -
Config: compared as a tree, with every scalar compared on both its lexeme — theyaml.Node.Valuestring — and its re-parsedyaml.Node.Tag, against the corresponding number or string as it appears in the stored JSON. The expected tag is!!strfor every JSON string and every mapping key; for a JSON number the assertion is that the tag is not!!str, which is the observable form of AC-41's "emitted as an unquoted YAML number". The fixture shall include at least one string value whose text would resolve to another YAML type if emitted bare —"true","null","1.0","0755","12"or"~". This is why the parse target isyaml.Nodeand notmap[string]any: amap[string]anydecode discards the emitted characters (1.0reads back asfloat64(1)) and would silently pass an AC-41 violation.(The tag half is not redundant with the lexeme half and the fixture requirement is not decoration — together they are what makes this test able to fail. A JSON string
"true"emitted bare re-parses withValue == "true"andTag == "!!bool": the lexeme matches the stored string exactly, so a lexeme-only comparison passes on corrupted output. And a fixture built only from ordinary strings never produces a scalar whose bare form is ambiguous, so the tag assertion would pass vacuously. The defect this guards is a type change with no textual trace, which is precisely the class the lexeme comparison was chosen to catch for numbers and is structurally blind to for strings.)
(Recovery is pinned to a generic node parse rather than to a typed decode on purpose: a typed decode would be a second implementation of the exporter's own field list and would inherit its blind spots — a field dropped from both sides would still pass. For the same reason the expected descriptors must be literals in the test rather than computed by exporter code; a shared projection reproduces the exporter's omission and the guard passes for the wrong reason. The applier is out of scope, so "recoverable" has no other reader to define it.)
File layout and naming
AC-24 — When the zip export is requested, the system shall write one file per
automation at .kandev/automations/<slug>.yml, each containing the full envelope
(version, type, automations) with a single-element automations list.
AC-25 — The system shall derive <slug> from the automation name by lowercasing,
replacing every character outside [a-z0-9] with -, collapsing consecutive -,
trimming leading and trailing -, and truncating to 64 characters followed by a further
trailing-- trim.
(Verified by running this algorithm over all 7 live names: every one yields a distinct
slug, the longest being 53 characters, so the 64-character truncation is not exercised by
the current corpus. Daily Review — @kegmil/offline-first yields
daily-review-kegmil-offline-first; Daily km-mobile-app-v2 repo drift --all yields
daily-km-mobile-app-v2-repo-drift-all. Office's config export writes
.kandev/agents/<Name>.yml using the raw name with no sanitization at all; 3 of the 7
live automation names contain /, which under that approach silently creates a nested
path instead of a file.)
AC-26 — If the derived slug is empty, then the system shall use automation as the
slug. (Reachable and verified: the name 日次レビュー collapses to the empty string under
AC-25 and must fall back.)
AC-27 — If two automations in one export derive the same slug, then the system shall
append - followed by the first 8 characters of each colliding automation's id, applied
to every member of the colliding group so no member keeps the bare slug. If any resulting
name still collides with another entry in the same export — whether with a suffixed or an
unsuffixed one — the system shall lengthen the suffix for the still-colliding entries, 8
characters at a time, up to the full id, until every entry path in the archive is unique.
(Automation names are not unique: neither automations nor Service.CreateAutomation
enforces uniqueness — only non-emptiness. Two id values sharing their first 8 characters
is vanishingly unlikely in a real workspace, and id is unique, so widening to the full
value always terminates. This is specified for the same reason AC-26 specifies the
empty-slug fallback for a case the live corpus does not contain: AC-24 promises one file
per automation, and a silent overwrite would break that promise by losing an automation
entirely rather than by failing.)
AC-28 — When the zip export is requested, the system shall order zip entries by entry
path ascending, and shall leave each entry's modification time unset so no wall-clock value
enters the archive. (Entry order alone does not make a zip reproducible — timestamps do
too. Probed: zip.Writer.Create leaves FileHeader.Modified zero and produces
byte-identical archives across runs for identical content, and is byte-identical to
CreateHeader with Modified unset. Office's export is therefore reproducible by accident
rather than by contract; AC-9 now binds the archive bytes, so this is pinned deliberately.)
Consistency, permissions, failure
AC-29 — When an export is served, the system shall read the automations, their triggers, their repository links, and every row it resolves a portable descriptor from, within a single read transaction opened on the store's reader handle, so a concurrent create, update, or delete cannot produce a document containing a trigger whose automation is absent, an automation missing triggers it holds, or a descriptor resolved against a different snapshot than the row referencing it.
The export shall establish that transaction's snapshot immediately upon opening it, by issuing
its first read inside the transaction before performing any other work. This is what makes
## Definitions' start a single well-defined moment rather than an interval, and it is what
AC-13 and AC-30 are stated against.
(SQLite's deferred transaction takes its snapshot at the first read, not at BEGIN. Left to the
builder, the gap between the two is unbounded — descriptor-lookup wiring, slug precomputation and
warning-buffer setup could all run inside it — and any commit landing in that gap is visible to an
export that had already "started". AC-13 was falsifiable that way by an implementation doing
nothing wrong; see ## Definitions for the trace. Closing the gap costs one read.)
Satisfying this requires new read methods on the automation store that accept the transaction,
and that work is in scope for this card. Store today splits db (writer) and ro (reader),
every read path takes only a ctx and issues against s.ro directly, BeginTxx is called only on
the writer, and no read transaction on the reader handle exists anywhere in the backend — so there
is nothing to reuse. See ## Decisions › "Export is additive and read-only" for why adding them
does not contradict that decision.
Descriptor rows are covered by this AC too, and the existing lookup seams cannot carry them. The automation package's four injected lookups return the wrong data for AC-18 and none accepts a transaction:
| Existing seam | Returns | Why it cannot serve the export |
|---|---|---|
AgentProfileLookup.AgentProfileExists(ctx, id) |
(bool, error) |
AC-18 needs {agent_name, model, mode} |
WorkflowLocator.WorkflowWorkspaceID(ctx, id) |
(string, error) |
AC-18 needs the workflow name and the step name |
RepositoryLookup.GetRepository(ctx, id) |
(workspaceID, defaultBranch string, ok bool) |
AC-18 needs the repository name; also two-valued, which AC-19 forbids |
| (none) | — | No executor-profile lookup exists on the automation service at all |
The export shall therefore define new, transaction-accepting descriptor lookups — one each for
agent profile, executor profile, workflow-and-step, and repository — following the established
SetWorkflowLocator / SetRepositoryLookup injection pattern: the interfaces are declared in the
automation package, each method takes the export's read transaction — the *sqlx.Tx opened on the
store's reader handle — as its first argument after ctx, and each reports the three outcomes
AC-19 requires.
Where the SQL lives is part of this contract. Each new lookup shall be satisfied by a new
exported, transaction-accepting read method on the store that owns the table — the
agent-settings store, the workflow repository, and the repository store respectively — and the
adapter constructed in backendapp shall do nothing but call that method, passing the transaction
through. No SQL text shall be written in backendapp.
(This is stated because the AC is otherwise satisfiable two ways and only one of them is
consistent with the codebase. Verified: internal/backendapp today contains no raw SQL and no
*sqlx.Tx usage, every existing Set*Lookup adapter delegates to the owning package's exported
method, and the only *sqlx.Tx parameters in internal/workflow/repository and
internal/agent/settings/store are unexported helpers operating inside those packages' own
transactions. An adapter issuing its own SELECT against agent_profiles, workflows,
workflow_steps or repositories would be the first of its kind in the tree, and would put query
text for four tables in a package that owns none of them — with no compile-time signal when an
owning package renames a column. That is precisely the silent drift AC-22 and AC-43 exist to
prevent for this export's own fields, reintroduced one layer out. Delegation keeps each query
beside the schema it reads and keeps the seam doing what every other seam here does.
The seams exist so the automation package does not import the task, agent-settings or repository
services and create a cyclic import — AC-44 step 2 relies on the same rationale — so moving the
SQL into the automation package is not available either. Passing the transaction across the seam
costs nothing new: the automation package already depends on sqlx for its own Store, every one
of these tables lives in the same SQLite database, and one read transaction can read all of them.
The alternative considered and rejected was to exempt descriptor rows from this AC, which would mean a descriptor resolved against a different snapshot than the automation referencing it — exactly the inconsistency the AC exists to prevent, and the one a git-committed artifact would preserve forever.)
This AC is observed by a unit test, not by a race: the export path shall obtain its snapshot once and pass it to every read — store reads and descriptor lookups alike — and the test asserts, with a store double and four lookup doubles that each record the handle they were given, that every recorded handle is the same one and that no read arrived with no handle.
(Stated because a race is not deterministically testable, and an AC whose only verification is "run it and hope" is one a builder can satisfy by doing nothing. Making the shared handle the observable turns a timing property into a structural one. The lookup doubles are named explicitly because a test that instruments only the store passes while every descriptor read runs outside the transaction, which was true of an earlier draft of this AC.)
AC-30 — While an export is in flight, a concurrent mutation of the same workspace's
automations shall neither block nor be blocked by it; the export reflects the snapshot
open at its start, as ## Definitions fixes that term — the moment its read transaction
establishes its snapshot, which AC-29 requires to happen immediately upon opening the
transaction.
(Probed, not reasoned, because an earlier round cleared this claim on general grounds that turned
out to be incomplete. The probe replicates both DSNs from internal/db/sqlite.go exactly — writer
_foreign_keys=on&_mode=rwc&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_cache=shared,
reader _foreign_keys=on&_mode=ro&_busy_timeout=5000&_cache=shared — opens a read transaction on
the reader pool, establishes its snapshot with a first read, and then writes the same table on the
writer pool. Result: journal_mode = wal; the UPDATE and a subsequent INSERT each succeeded
immediately with no block; and the still-open read transaction continued to see the pre-write
value, so snapshot isolation holds. A read transaction opened after the write also did not block.
AC-30 stands, with evidence.
One dependency is worth naming, because it is not what it appears to be. Both DSNs carry
_cache=shared, and SQLite shared-cache mode uses table-level locks under which this AC would
fail. It does not fail, because _cache=shared is inert: SQLite's URI parameter is cache,
and the driver's own parameters are _busy_timeout, _journal_mode and friends — neither honours
_cache, so shared cache is never enabled. Probed side by side against the same code path:
_cache=shared and no cache parameter at all behave identically (write succeeds), while a true
cache=shared fails the write immediately with database table is locked: automations — and
immediately, because busy_timeout does not retry SQLITE_LOCKED. If that DSN is ever
"corrected" to cache=shared, this AC breaks at once and hard. That is a pre-existing
observation about the database configuration, not a defect this card introduces or fixes; it is
recorded here so the dependency is visible rather than accidental.)
AC-31 — When a user without access to the target workspace requests an export, the system
shall deny the request through the same workspace authorizer used by every other automation
operation (Service.authorizeWorkspace, wired to taskSvc.AuthorizeWorkspaceAccess), return
404, and return no automation data and no error text distinguishing denial from absence.
(404 and not 403, deliberately. ErrAutomationNotFound is documented as covering "both a
missing automation and one in a workspace the caller cannot reach, so authorization never leaks
which of the two it is". A 403/404 split on this endpoint would reintroduce exactly the
existence leak that sentinel exists to prevent: a caller could enumerate which workspace IDs are
real by reading the status code.)
(Testing note, because the obvious fixture observes the opposite: the authorizer returns nil
without checking anything when the caller context carries no user scope, which is Kandev's default
single-user mode. A test for this AC must supply a scoped caller context; run unscoped it
observes 200 and the AC looks wrong.)
AC-44 — The system shall evaluate the request in this order, and shall emit no automation data unless every step passes:
- Authorize the workspace through
Service.authorizeWorkspace. Classify the result by sentinel, not by position:nil→ continue; an error satisfyingerrors.Is(err, repoerrors.ErrWorkspaceNotFound)→404(AC-31); any other non-nil error →500(AC-45). - Resolve the workspace through a workspace lookup injected into the automation service at
construction, following the existing
SetWorkflowLocator/SetRepositoryLookupinjection precedent — the seam exists so the package does not import the task service and create a cyclic import.repoerrors.ErrWorkspaceNotFound→404(AC-35); any other non-nil error →500(AC-45); lookup not wired →500, since the endpoint cannot honour AC-35 without it and an unwired lookup is a construction error rather than a deployment mode. - Export. An authorized, existing workspace holding no automations →
200with an empty list (AC-32).
(The sentinel rule in step 1 is spelled out because the wired authorizer makes the naive reading
unsafe. taskSvc.AuthorizeWorkspaceAccess returns the same repoerrors.ErrWorkspaceNotFound
for a denied workspace and for a workspace that does not exist, and returns any infrastructure
error from its own workspace read bare on the same path. A builder who reads "denial" as "the
authorizer said no" and treats a not-found error as "a failure that is not a denial" returns 500
where AC-31 and AC-35 both mandate 404 — which re-opens, with the status code as the oracle,
precisely the enumeration leak AC-31 exists to close. Classifying on the sentinel makes denial and
absence indistinguishable, which is the required behaviour, and leaves 500 for the errors that
genuinely are failures.)
(Why step 2 exists, stated precisely because the general claim is false: for a scoped caller
step 1 already answers existence, since the authorizer's own workspace read produces
ErrWorkspaceNotFound for a workspace that is not there. Step 2 is the path that carries AC-35
for an unscoped caller — Kandev's default mode — where step 1 returns nil without reading
anything and Service.ListAutomations cannot help: the store returns an empty slice for a
workspace ID that never existed, indistinguishable from a real but empty workspace. The lookup is
therefore required and is not dead code, even though a scoped-context test will never reach it.
The automation package holds no workspace lookup of any kind today. Office's config export, which
this endpoint otherwise mirrors, never returns 404 at all — it returns 500 on any error — so
the precedent is not usable here.)
(Step 2 and step 3 read from different snapshots and the gap between them is not closed. If the
workspace is deleted after step 2 succeeds, the export returns 200 with whatever the AC-29
snapshot holds, which for a cascaded delete is an empty list. This is an accepted race: the
existence check is a gate against a workspace ID that was never real, not a guarantee that the
workspace outlives the request, and paying for the stronger guarantee would mean holding the
workspace row in the export's own transaction across a service boundary the injection seam exists
to avoid.)
AC-32 — When a workspace has no automations, the system shall return a valid document
with version, type, and an empty automations list, and the zip form shall be a valid
archive containing no automation files. This is not an error.
AC-33 — When an automation's stored name is the empty string, the system shall emit
name: "" and derive its filename through the AC-26 fallback. (Service.CreateAutomation
rejects an empty name, but the automations.name column permits one, so a row written by
any other path is representable.)
AC-34 — When an automation's stored max_concurrent_runs is zero or negative, the
system shall emit the stored value unchanged rather than substituting the service's
1 default. (Export reports what is in the database; it is not a validation pass.)
AC-35 — If the target workspace does not exist, then the system shall return 404 and
no partial document, indistinguishable in status and body from the AC-31 denial. The
existence answer comes from the lookup named in AC-44 step 2.
AC-36 — If serialization of any automation fails, then the system shall return 500
and no partial document. A partially-written body is never emitted.
AC-45 — If the read transaction cannot be opened, any query fails, the workspace lookup
fails for a reason other than not-found, the authorizer fails for a reason other than
denial, or the zip archive cannot be written, then the system shall return 500 with no
automation data and no partial body.
AC-48 — The system shall build each response body completely in memory before writing any of it to the response, and shall write the status line only once the body is known to be complete.
(AC-36 and AC-45 both promise "no partial body", and that promise is unimplementable in
the streaming shape Office uses: exportConfigZip sets 200 and the Content-Disposition
header and then io.Copys the archive to the writer, so a failure mid-copy has already sent
200 and cannot retract it — the client receives a truncated archive under a success
status. Buffering is what makes the guarantee real. The measured corpus is ~78 KB of prompt
across 7 automations, so the memory cost of buffering the whole artifact is not a concern at
this scale.)
User surface
AC-37 — When a user opens a workspace's automations settings page, the system shall offer an
export control that downloads the zip form. The control shall be present when the page itself is
(a user who can open the page has passed the same workspace check the export authorizes against),
shall be disabled from the moment the request is issued until the download has been handed to the
browser or an error has been surfaced, and shall surface a non-blocking error message if the
response status is not 200, leaving the page otherwise unchanged. A workspace with no automations
still shows the control enabled — AC-32 makes that a valid export, not an error.
The control shall issue the request with fetch, check the response status, read the body to a
Blob, and trigger the download from an object URL with an explicit filename of
kandev-automations.zip. It shall not navigate to the endpoint with window.open or an anchor
href.
(The mechanism is pinned because the two halves of this AC are unimplementable without it, and the
precedent this spec otherwise mirrors gets it wrong. Office's equivalent control is
window.open(officeApi.exportConfigZipUrl(activeWorkspaceId), "_blank"): a raw navigation gives the
calling code no completion signal and no access to the response status, so there is nothing to
disable on and nothing to test for. Worse, a navigation to a non-200 renders the error body in a
blank tab, which is the opposite of "leaving the page otherwise unchanged". Under fetch the
filename comes from the object URL's download attribute rather than from Content-Disposition;
the header stays on the response for anyone hitting the URL directly, but it is not what names this
download. "In flight" is defined above as the whole span from issue to hand-off precisely because
"until headers arrive" and "until the body is read" are different windows and only the wider one
prevents the second click AC-37 is guarding against.)
AC-38 — All copy introduced by the export control shall be resolved through t() or
<Trans> and shall be present in en, pt-pt, zh-cn, zh-hk, and zh-tw.
Failure modes
Every row cites the AC that makes it testable. A row without one is a shadow path.
| Condition | Behaviour |
|---|---|
| Workspace has no automations | 200, empty automations list (AC-32) |
| Workspace does not exist | 404, body indistinguishable from denial (AC-35, AC-44) |
| Caller lacks workspace access | 404, no data, no distinguishing text (AC-31, AC-44) |
| Workspace lookup not wired | 500 (AC-44 step 2) |
| Agent/executor profile or repository row is absent | Reference omitted, warning emitted, export succeeds (AC-19) |
| A descriptor lookup fails rather than reporting absence | 500, no partial document, no warning — absence and failure are distinct outcomes (AC-19, AC-45) |
| Workflow resolves but its step does not | workflow.name emitted without step, warning emitted (AC-19) |
| Some repositories resolve, others do not | Unresolved members dropped, resolved members kept in order, warning emitted (AC-19) |
Trigger config is not valid UTF-8 |
Empty config mapping, config is not valid UTF-8 warning; checked on raw bytes before decoding and takes precedence over the next two rows, so exactly one warning is emitted (AC-39) |
Trigger config is not valid JSON |
Empty config mapping, warning naming automation + trigger type + condition; export succeeds (AC-39) |
Trigger config is valid JSON but not an object |
Same as above (AC-39) |
Trigger config holds a number |
Emitted unquoted and untagged, character-identical to storage; never reformatted, rounded, quoted, or given an explicit !!int/!!float tag (AC-41) |
Trigger config keys contain digits or punctuation |
Sorted byte-wise via an explicit MappingNode, not by yaml.v3's map sorter (AC-8) |
Trigger config holds a string whose text would resolve to another YAML type ("true", "null", "1.0", "0755", "12", "~") |
Emitted with an explicit !!str tag, so yaml.v3 quotes it and it re-parses as a string; never retyped to bool, null, int or float (AC-8, AC-23) |
| Multi-line valid-UTF-8 prompt, emitted scalar is a literal block scalar (any chomping indicator) | Emitted as-is, no warning (AC-16) |
| Multi-line valid-UTF-8 prompt, emitted scalar is not a literal block scalar | Emitted faithfully in whatever form yaml.v3 chose, exactly one prompt not emitted as a block scalar warning; export succeeds. The condition is read from the emitted node's style, never from the prompt's characters (AC-17) |
| Multi-line prompt contains a TAB | Block scalar retained, no warning — a TAB does not clear block_allowed, and this row is a consequence of the style check rather than a special case in it (AC-16) |
| Prompt contains invalid UTF-8 | Emitted as !!binary, invalid UTF-8 warning added, 200 — not a serialization failure; wins over AC-46 and AC-17 whether or not the prompt has a newline, and is the only reason emitted (AC-47) |
| Single-line non-empty prompt, valid UTF-8 | Emitted as YAML requires, no warning — a block scalar is unreachable without a newline, so this is not a degradation (AC-46) |
| Prompt is the empty string | prompt omitted entirely; no prompt AC applies and no warning is emitted (AC-4, AC-46) |
| Valid-UTF-8 prompt whose default emission does not decode back byte-for-byte (a prompt beginning with a newline is the reachable case) | Re-emitted as a double-quoted !!str scalar, which is probed to round-trip; exactly one prompt re-quoted to preserve bytes warning; AC-17 suppressed for that prompt so only one prompt warning is emitted; 200 (AC-49, AC-15, AC-16, AC-17) |
| A prompt that round-trips under neither the default nor the double-quoted form | 500, no partial document — corrupt bytes are never committed. No probed input reaches this (AC-49, AC-36) |
| Prompt is invalid UTF-8 and would fail the fidelity test | AC-47 wins and is evaluated first; fidelity comes from base64-decoding, not from comparing the re-parsed scalar, whose value is the base64 text (AC-47, AC-49) |
| Multi-line prompt whose only line break is CR, U+0085, U+2028 or U+2029 | Treated as multi-line, since ## Definitions fixes newline as YAML's five-character break set; emits non-literal, so exactly one prompt not emitted as a block scalar warning (AC-17, AC-46) |
workflow_id set, workflow_step_id empty |
workflow emitted with name only, no warning — nothing was referenced, so nothing is unresolved (AC-19, AC-4) |
| Two automations derive the same slug | Suffixed with id prefix, lengthened until unique (AC-27) |
Authorizer returns a non-ErrWorkspaceNotFound error |
500 — not 404; the sentinel is what distinguishes denial (AC-44 step 1, AC-45) |
| Workspace deleted between the existence check and the snapshot | 200 with an empty list; accepted race (AC-44) |
| Two same-named automations emit an identical warning | Both kept — dedup is never global (AC-42, AC-19) |
Two same-type triggers on one automation both have bad config |
Both warnings kept — AC-39 messages dedup per trigger, not per automation, so the count is not lost (AC-42, AC-39) |
| Automation name or trigger type contains a newline or control character | Escaped before interpolation so the warning stays one line. "Newline" is the five-character set, so U+0085 escapes as \x85 and U+2028/U+2029 as \u2028/\u2029; without this a raw U+2028 survives into both the YAML scalar and WARNINGS.txt, where a reader honouring YAML's break set sees two lines (AC-42) |
| Automation name or trigger type contains an invalid UTF-8 byte | Escaped as \xNN before interpolation, so the warning stays a UTF-8 string scalar and does not become !!binary (AC-42) |
| A concurrent mutation lands while an export is in flight | Neither blocks the other; the export keeps its snapshot. Probed against both real DSNs (AC-30) |
| A mutation commits between two exports opening their transactions | Each export's start is its snapshot establishment, and AC-29 requires that to happen immediately on opening, so the window is one read wide and AC-13 is stated against a defined moment (AC-13, AC-29, AC-30) |
| Read transaction, query, or zip write failure | 500, no partial body (AC-45, AC-48) |
| Serialization error | 500, no partial body (AC-36, AC-48) |
Persistence guarantees
The export performs no writes. No table is modified, no last_evaluated_at is touched,
no run row is created. Exporting an automation has no effect on when it next fires.
Out of scope
Named exclusions, not omissions:
- The applier (Phase 2). Reading exported YAML back into the database is not
specified here. It is additionally gated on an unresolved upstream question — whether
kdlbs/kandevconsiders automations a git-syncable entity at all — tracked in Office discovery taskba70553d. If the answer is no, Phase 2 is a carried topic branch rather than an upstream contribution. - Two-way sync (Kandev writing back to git).
- A poller. Nothing is fetched from a repository on an interval.
- Migrating agent or executor profiles into git. They stay database-resident; the export references them by descriptor, which is why AC-19 needs a warning path.
automation_runs. Run history is observability, not definition, and is never exported.- Importing Office
ConfigBundleroutines. A different entity in a different package. - Fixing the Office
reports_toimport defect. Referenced as a caution; it has its own card. - Cross-workspace export. Every endpoint is scoped to one workspace.
- Secret rotation or a secret-bearing export variant.
webhook_secrethas a dedicated reveal endpoint and stays there.