This document is normative. It describes the state the package manager keeps on
the SD card so that update and remove can work correctly. Its machine-checkable
subset is schema/edgetx-state.v1.json,
which is normative too. Terminology follows
RFC 2119, as set out in
Manifest.md.
- Reserved namespace
PKG/packages/<package-key>.ymlPKG/packages/<package-key>.list- Resource limits
- Durability
- Orphan removal and dependency reasons
- Package id as a key
- Validation summary
- Migration
All package manager state lives in a single top-level directory on the SD card:
PKG/
├── packages/
│ ├── github.com%offer-shmuely%lua-scripts%log-viewer~9f1c2ab0.yml
│ └── github.com%offer-shmuely%lua-scripts%log-viewer~9f1c2ab0.list
└── .operation # present only while an operation is running
PKG/ is reserved for the package manager. Tooling MUST reject any content
destination beginning with PKG/, compared case-insensitively, and MUST NOT
store package content there. FAT32 does not distinguish PKG from pkg, so a
case-sensitive check would let dest: pkg/packages/hijack.yml reach the state
directory.
PKG/ sits at the SD card root alongside the other purpose-owned directories
(SCRIPTS/, WIDGETS/, THEMES/, SOUNDS/, IMAGES/, MODELS/, RADIO/,
FIRMWARE/). Keeping it separate from RADIO/, which holds firmware settings,
means package state has exactly one owner — but deleting it is destructive
tracking loss, not a routine recovery step. Deleting PKG/ makes the
package manager forget every installed package while leaving their files in
place. Those files are then untracked. To recover, first inspect or reconcile
what is on the card; if the files should be re-adopted, reinstall with an
explicit overwrite policy. Under the required fail default, a blind
reinstall stops at the first collision and reports it.
One file per installed package.
edgetx_format_version: "1.1"
id: github.com/offer-shmuely/lua-scripts/log-viewer
name: Log Viewer
version: "1.2.0"
variant: "edgetx.color.yml"
reason: explicit
installed_at: "2026-08-23T12:40:00Z"
source:
repo: github.com/offer-shmuely/lua-scripts
channel: tag
ref: "v1.2.0"
commit: "9f1c2ab0d4e7a3b6c9f2e5a8d1b4c7e0a3f6b9c2"
origin: null
path: null
manifest_path: "log-viewer/edgetx.yml"
requires:
- id: github.com/someone/elrs-libs
version: "^2.0.0"
commit: "a4d81f0c9e2b7a6f3d5c8e1b0a9f4c7d2e6b8a15"Tooling MUST write every field marked required, and MUST accept a package-state file in
which any optional field is absent or null. An absent field and an explicit
null are equivalent.
| Field | Required | Description |
|---|---|---|
id |
yes | The package's canonical id, from its manifest. Primary key: (id, commit), with commit taken from source.commit when present. |
version |
yes | The package.version recorded at install, or null when the manifest declared none. Compared against a newly resolved manifest to detect updates. |
reason |
yes | explicit when the user asked for this package; dependency when it was installed to satisfy a requires entry. Drives orphan cleanup. |
source |
yes | Where the package came from — see below. |
variant |
no | The selected variant, or null. See variant. |
name |
no | Display name copied from the manifest. Presentation only; tooling MUST NOT key on it. |
installed_at |
no | RFC 3339 timestamp with the Z UTC designator. Offset forms such as +00:00 MUST NOT be used, so the value sorts and compares textually. |
requires |
yes | Snapshot of the manifest's requires list, [] when the manifest declares none. Each entry records the requested dependency id and range, and MAY also record the resolved dependency commit — see Dependency snapshot. |
<package-key> is the lowercased id with each / replaced by %, followed
by ~, followed by:
- the first 8 lowercase hex characters of
source.commit, whencommitis present; or - the literal
local, whenchannelislocal.
Examples:
github.com/offer-shmuely/lua-scripts/log-viewerat commit9f1c2ab0d4e7a3b6c9f2e5a8d1b4c7e0a3f6b9c2becomesgithub.com%offer-shmuely%lua-scripts%log-viewer~9f1c2ab0github.com/acme/work-in-progressfrom a host directory becomesgithub.com%acme%work-in-progress~local
The mapping from id to the prefix is the same reversible FAT32-safe mapping
used before for file lists: % cannot appear in an id segment, so replacing /
with % is unambiguous. The ~<commit> suffix separates multiple installed
versions of one package without relying on case distinctions FAT32 does not
preserve.
Because the longest sibling filename is .list, id is capped at 241
characters: 241 + 1 + 8 + 5 = 255, exactly FAT32's per-name limit.
| Field | Required | Description |
|---|---|---|
channel |
yes | tag, branch, commit or local. How the version was resolved. |
repo |
yes | The repository the id designates — the id minus any subpackage path. For github.com/owner/repo/log-viewer this is github.com/owner/repo, since that is what gets cloned. It MUST equal the id or be a prefix of it ending at a /. That is a necessary condition and not a sufficient one: how many segments a repository has is not fixed, because hosts nest differently — a GitLab subgroup makes gitlab.com/group/sub/project one repository — so a prefix that stops in the wrong place cannot be detected here and is caught by the fetch instead. |
commit |
yes except local |
The full resolved commit object id in lowercase hex: 40 characters for SHA-1, 64 for a SHA-256 repository. Abbreviated ids MUST NOT be recorded. Its first 8 characters key the state and list filenames. |
ref |
no | The tag or branch name resolution chose — a dependency resolves to a tag, not merely a version, and this is where that choice is recorded. null for commit and local. This value is read off a removable card and handed to a fetch, so it is constrained like a path: no control characters, no leading - that a command line would read as an option, and only characters a git refname may contain. |
origin |
no | Set when the package was fetched from somewhere other than its declared id — a fork. null otherwise. When present, update MUST re-resolve from origin, not from repo: the user chose the fork, and silently migrating them back upstream would install different code than they asked for. |
path |
no | Absolute path on the host filesystem, for channel: local. Update re-reads the package from here; when the directory is gone, tooling reports that rather than treating the package as up to date. For a local install the symlink anchor is this directory, since there is no checkout. This is the one path in state that is not an SD card path, so the path rules do not apply to it — but it MUST carry no control characters. POSIX absolute paths and Windows drive-absolute paths such as C:/repo and C:\repo are valid. null otherwise. |
manifest_path |
yes | Repo-relative path of the manifest that was loaded. When a variant was selected this is the base manifest, so update re-runs variant selection from the same starting point. |
Tooling MUST record source.commit whenever it is known. Without it a
force-pushed tag or branch is undetectable, and there is no way to say what is
actually installed. version answers whether an update exists; commit
answers what is installed.
variant stores the exact string from the base manifest's
variants[].path — not a basename. If the base declares
path: variants/color.yml, state records variants/color.yml. This is what
makes variant comparable against the new manifest's variants list on
update.
Whether dev: true content was included is deliberately not recorded.
--dev is a developer's flag; each operation states what it wants, and no
operation infers it from an earlier one. An install with --dev followed by a
bare update leaves only the shipped content, and that is intended. See
Manifest.md.
requires mirrors the manifest's requires list at install time, as [] when
the manifest declares none. Tooling MUST record it, and every conforming
implementation does: tooling that can fetch resolves requires, and tooling that
cannot still has to know what a package asked for in order to refuse it and name
what is missing. See Manifest.md.
Each snapshot entry records the dependency id and requested version range.
When resolution chose one installed version among several packages sharing that
id, the snapshot entry SHOULD also record the resolved dependency commit.
That field is what lets orphan cleanup and remove-time reachability follow the
same installed dependency later without re-solving history from the network.
When only one installed version of that id exists, commit MAY be omitted for
backward compatibility with older state files.
Without the snapshot neither rule is computable offline: deciding whether a
reason: dependency package is still needed means knowing what every installed
package requires, and manifests are not kept on the card. State exists to answer
questions about installed packages without going back to the network — but only
questions whose answers cannot be recomputed, which is why the snapshot is here
and a compatibility verdict is not.
Every package-state file carries the same edgetx_format_version as a manifest,
with the same numbering — one number covers both formats because they are
released together. The rules differ, because state is written as well as read:
- Tooling MUST refuse to read state whose MAJOR is greater than its own, and say that newer tooling is required. Listing packages out of a format you do not understand invites acting on a misreading — a manifest gets the same treatment, see Manifest.md.
- Tooling MUST refuse to write to such state for the same reason, and because rewriting it would silently discard fields it does not recognise.
- Tooling MUST read state with a MAJOR less than or equal to its own, and MUST tolerate a higher MINOR by ignoring fields it does not recognise.
- Absence means
"1.0", and MUST NOT be warned about.
One file per package, listing every file that package installed.
The list file is the state file's sibling with the same <package-key> base,
for example:
PKG/packages/github.com%offer-shmuely%lua-scripts%log-viewer~9f1c2ab0.list
Inside that PKG/packages/<package-key>.list file, each line is one
SD-card-relative path, /-separated, LF or CRLF terminated:
SCRIPTS/TOOLS/LogViewer/main.lua
SCRIPTS/TOOLS/LogViewer/lib/parse.lua
SCRIPTS/LIBS/Common/init.lua
- Every line MUST satisfy the path rules and MUST
NOT be
PKGor begin withPKG/, compared case-insensitively — a file list records installed destinations, so it carries the destination rules and not merely the path rules, which deliberately permitPKG/for repository-relative paths. A line naming the state file is a delete primitive against the state file. The path rules exclude control characters, so no path can span or forge a line and no escaping mechanism is needed. - Blank lines MUST be ignored.
- A line that violates the path rules MUST be reported and MUST NOT be acted on. Deleting a path read blindly from a removable card is how a state file becomes a weapon.
A sibling list file avoids re-reading every package's inventory for every operation, and makes removal a single file delete beside the package-state file.
Fixtures for this format live in
conformance/file-lists/; it has no JSON Schema,
so the runner checks each line against the same pattern the manifest schema
applies to dest.
-
Ownership is tracked per file, not per directory or per content item. Two packages MAY install into the same directory as long as no individual file collides.
-
Directories are never owned. On remove, tooling deletes the package's files, then prunes directories that are now empty. It MUST NOT delete a non-empty directory and MUST NOT prune above the SD card root or into
PKG/. -
Comparison of two paths is case-insensitive, after applying the path rules — see Manifest.md, which is canonical. The path rules remove the redundant spellings (
.segments, doubled separators, trailing spaces and dots) and case folding removes the rest. Comparing case-sensitively here would let a package take over another's files by changing the case of adest, which is exactly what per-file ownership exists to prevent. -
Before install, tooling MUST check every destination file against the file lists of other packages. Reconciliation MUST also report two lists claiming one destination: it cannot arise from a conforming install, but it can from a hand-edited card or an interrupted operation, and until it is resolved removing either package deletes the other's file. A collision with a file owned by a different package is an error naming the owner.
-
A destination file that exists but is owned by no package is untracked. Overwriting it is destructive, so tooling MUST apply an explicit overwrite policy rather than deciding silently. The policy MUST be one of:
Policy Behaviour failRefuse the operation, naming the file. The required default when no user is present — CI, scripted, or agent-driven runs. overwriteOverwrite the file — or, during a remove, delete it — and report each one. promptAsk, per file. Declining is equivalent to fail.The policy is an input to the operation, never a question asked from inside it. See Implementation.md for the worked routine.
-
Reinstalling or updating a package MAY overwrite files it already owns.
EdgeTX runs .luac bytecode in preference to a .lua source of the same name,
so a stale .luac silently shadows a newer script. Two rules follow from that,
and both are about untracked bytecode — the radio compiles main.lua to
main.luac beside the source the first time it runs it, so a card in use is full
of the stuff, some of it derived and some of it written by the user.
Throughout this section the extension comparison is case-insensitive, like every
other destination comparison, and the sibling sought is the destination's own
spelling with c appended or removed.
Writing or deleting a
.lua. Tooling MUST delete the sibling.luacif it exists and no package owns it, subject to the same overwrite policy as any other untracked file — except as the derived-bytecode exception below allows.Writing a
.luac. Tooling MUST apply that same policy to an untracked.luasibling, and to any untracked file already at the destination. Underoverwritethe user's.luais left in place, not deleted: it is being shadowed rather than overwritten, and removing a file the package never declared would be worse. The policy's job here is only to make sure somebody chose.Because a sibling is never in staging, the collision check cannot see it. Tooling MUST therefore scan for the siblings it would touch and apply the policy before writing the operation marker — a separate pass, for install, update, reinstall and remove alike. Refusing after the marker is written leaves a card permanently reporting an unfinished operation.
Without the policy a package could delete any of the user's bytecode by declaring
a .lua at its path, and neither the collision check nor the untracked-file
warning would see it. That applies to remove as much as to install, so remove
takes an overwrite policy too: otherwise a package that declares a one-line .lua
beside a user's hand-compiled bytecode deletes it on uninstall, and the attacker
need only wait.
During an install, update or reinstall, when the operating package already
owned the .lua at that destination before this operation began, an untracked
.luac there is that script's own compiled output: tooling deletes or overwrites
it without applying the policy.
Without the exception the policy is unusable — every package the user has ever
run has derived bytecode beside its source, so the required fail default would
refuse every unattended update of every such package, and the interactive path
would prompt about a file the package itself caused to exist.
Three parts of that sentence are load-bearing:
- Before this operation began means the file list on the card, not the list being staged. The staged list already claims the destination — that is what staging is — so testing against it fires the exception on a first install, which hands back exactly the delete primitive the policy exists to remove. Tooling MUST read the card's list.
- Not remove. On remove the package always owns the
.lua, so an exception scoped to ownership alone would always fire and would silently delete bytecode the user compiled deliberately. At remove time the radio's output and a deliberate override are indistinguishable, and nothing is being written that could shadow anything, so remove asks. - Deletes or overwrites, covering the destination itself and not only the
sibling. A package migrating from source to shipped bytecode declares
X.luacwhere it previously declaredX.lua, and the radio'sX.luacis sitting at exactly that destination. Without this, that migration refuses under the required default on every card where the script has ever been run.
A .luac that is owned by a package is ordinary content for copying and
removal — including in a package that ships bytecode and no source, which is
a supported shape rather than a degraded one; see
binary. None of the rules above reach it.
But it is not ordinary for conflict detection:
A destination
X.luaccollides with an ownedX.lua, and a destinationX.luacollides with an ownedX.luac, even though the names differ. Tooling MUST refuse such an install, naming the owner.
Without that rule a package can ship SCRIPTS/TOOLS/Popular.luac, set
binary: true, and have the radio execute it instead of another package's
SCRIPTS/TOOLS/Popular.lua — arbitrary code substitution with no collision
reported, because per-file ownership compares names and these two differ. The
.lua/.luac pair is the one case where two distinct names are one executable.
PKG/packages/*.yml is read on every operation and comes off a removable card,
so each package-state file gets the same treatment as a manifest — see
Manifest.md for the reasoning.
State MUST NOT record more than 512 packages in PKG/packages/, and MUST NOT
record more than 64 requires entries for any one package. A single file list
MUST NOT exceed 1 MiB or 8192 lines — it is the larger artifact, one content
item may name a directory of any size, and every operation reads every list to
answer ownership.
That bound is checked with the other refusable checks, before the operation marker is written — not at the point state is serialised, which is after the files are already on the card. Refusing there leaves the copy done and the marker standing, which is the wedge the ordering rule exists to prevent. It applies when writing as well as reading: tooling MUST refuse an install whose inventory would exceed it, naming the count, before anything reaches the card. A read-side bound alone is worse than none — a package of nine thousand files installs, and every subsequent operation on any package then refuses to load the list it must read to proceed, with the offending package removable only through the file the tooling will not open. Tooling MUST refuse to load state that exceeds these, reporting which bound was hit rather than failing obscurely, and MUST bound YAML alias expansion as a manifest does.
The goal is that an interrupted operation is detectable and recoverable by
the user, not that it is invisible. This is a hobbyist tool writing to a FAT32
card over USB mass storage: atomic rename and fsync do not have meaningful
guarantees there, and a browser-based implementation has neither. Specifying a
protocol that cannot be honoured would be worse than specifying a modest one
that can.
Requirements:
-
State writes are replace-in-place. Write each package-state file and each file list to a temporary name in the same directory, then replace the target. Tooling SHOULD request a flush where the platform offers one.
-
A state file that fails to parse MUST NOT be silently discarded. Report it and stop; do not overwrite it with an empty document. Losing state orphans every installed file.
-
An operation marker. Before modifying anything on the card — and after every check that can refuse the operation — tooling writes
PKG/.operation, and deletes it once the final state write completes. The ordering matters: a refusal that happens after the marker is written leaves the card reporting an unfinished operation forever, so a package that trips the overwrite policy could wedge an unattended tool with a single file.operation: install package_id: github.com/acme/my-tool started_at: "2026-08-23T12:40:00Z"
Field Required Description operationyes Exactly one of install,update,remove.package_idyes The package the user asked for. started_atyes RFC 3339 timestamp with the Zdesignator. Not nullable — the interruption report depends on it.The marker is YAML, and it is validated against
#/$defs/operationMarkerinschema/edgetx-state.v1.json— not against that schema's root, which describes one package-state file. One schema file carries both formats.package_idnames the package the user asked for. One marker covers the whole request: an install that also pulls in dependencies writes one marker for the requested package, not one per dependency, so an interruption is always reported against something the user recognises.That has a consequence for ordering, because "after every check that can refuse" and "one marker for the whole request" would otherwise conflict as soon as a package has dependencies. Resolve and stage the entire batch — the requested package and every dependency — and run every refusable check over all of it, before the single marker is written. Installing a dependency first and then discovering a conflict in the requester leaves a
reason: dependencypackage that nothing requires, or a marker naming a package the user has never heard of.Within a batch, staged destinations MUST be checked against each other as well as against installed packages. Neither is installed yet, so the ownership check alone sees no collision — and both file lists would then claim the same file, so removing either would delete the other's content.
That check covers equal destinations, ancestors, file-versus-directory kind, and the
.lua/.luacpair — the same ground as the card check, and the last of those is the one an implementation will miss. Against the card the sibling test works by asking who owns the counterpart, and ownership is read from file lists; no batch member has one yet, so that question returns "nobody" for every member and the test silently passes. It has to be asked of the batch directly.Missing it is not a tidiness problem. A package declaring
SCRIPTS/TOOLS/Popular.luacand naming the package that shipsSCRIPTS/TOOLS/Popular.luaas arequiresentry forces both into one batch, and the radio then executes the first package's bytecode wherever the second's script was called for — arbitrary code substitution, with two file lists that each look correct. Installing the same two packages one after the other is correctly refused, which is what makes the batch path a hole rather than a decision. One batch member staging the fileSCRIPTS/Twhile another stagesSCRIPTS/T/inner.luais a collision even though no two destinations are equal: the copy would fail partway through with the marker already written, which is the outcome the ordering rule above exists to prevent. Comparing destinations for equality alone leaves that case open, and an ordinary two-item manifest reaches it.Unknown fields MUST be ignored, as everywhere else — a later MINOR format revision may add one.
-
On startup, a present
PKG/.operationmeans the last operation did not finish. Tooling MUST report which package and which operation were interrupted, and MUST NOT silently continue as if state were consistent. It SHOULD offer to reconcile. Re-running the same install or remove can be part of recovery, but it is not automatically safe or sufficient: an interrupted install or update can leave copied files behind without matching ownership in state, so the requiredfaildefault rejects a blind rerun at the first collision; an interrupted remove can delete tracking before every file is gone, so there may be nothing left to rerun unattended.Recovery is therefore explicit:
- read and report
PKG/.operation, the affectedPKG/packages/*.ymlentries, and the package's file list when present; - inspect the card for files that were copied or left behind;
- run reconciliation, or manually compare state against the card, to decide whether files should be re-adopted, overwritten, or deleted;
- rerun install/update only with an explicit overwrite decision when stray files remain, and rerun remove only after tracking has been restored or leftover files have been cleaned up.
- read and report
-
Reconciliation. Tooling SHOULD provide a command that compares
PKG/packages/*.ymland the file lists against what is on the card, and reports: files recorded but missing, files present but unowned, packages whose state no longer matches the firmware, andreason: dependencypackages that nothing requires. This replaces automatic rollback: it is inspectable, it needs no shadow copies of user data, and it also repairs damage the package manager did not cause. Automatic orphan cleanup after install, update and remove uses the same reachability rule described in Orphan removal and dependency reasons.
Deliberately not required: per-file backups before overwrite, per-file checksums, lock files with process-liveness detection, and automatic transactional rollback. Each costs SD write volume, free space and complexity out of proportion to the failure it prevents, and none can be implemented uniformly across firmware, desktop and browser tooling. The worst realistic outcome without them — reinstall the package — is acceptable.
Tooling MUST serialise its own operations within a process. It is not required to defend against a second tool writing to the same card concurrently; that is the same hazard as unplugging the card mid-write, and the operation marker makes it detectable.
How a dependency gets recorded is covered by Dependency snapshot; this section is what that record is for.
A package installed only to satisfy another package's requires entry is
recorded with reason: dependency. It is a full package with its own state file
and its own file list — dependencies are not merged into their requirer.
- Tooling SHOULD scan every package-state file after install, update and remove
to find
reason: dependencypackages no remainingreason: explicitpackage transitively requires, computed from the dependency snapshots. - Reachability starts from every
reason: explicitpackage-state file. Follow eachrequiresentry by(id, commit)when the snapshot recordedcommit. When it did not, and exactly one installed package with thatidexists, tooling MAY follow that one byidalone for backward compatibility. - If a snapshot omits
commitand multiple installed versions of thatidexist, tooling MUST re-resolve before orphan cleanup or conservatively keep all matching versions. It MUST NOT guess which installed commit had satisfied the dependency. - Different requiring packages MAY therefore keep different commits of one dependency alive.
- A
reason: dependencypackage that is not reachable from any explicit root is an orphan. Tooling SHOULD remove it automatically by deleting its state file, sibling file list and owned files, then repeating the scan until no new orphans appear. - Tooling MUST NOT remove a
reason: explicitpackage as an orphan. - Installing a package that is already present as
reason: dependency, this time explicitly, promotes it toreason: explicit. This applies to the reinstall path too — an explicit install of an installed package goes through reinstall, so a reinstall that preservedreasonunchanged would make the promotion unreachable. - Removing a package MUST NOT leave another package's
requiresunsatisfied without saying so. Tooling SHOULD refuse, or warn and name the packages left without a dependency.
All of a package's content installs and is removed together, libraries included — ownership is per file, but the unit of install and removal is the package. See Manifest.md.
(id, commit)is the primary key. Two installed packages MAY share anidonly when their resolved commits differ, and each such version gets its own package-state file and sibling file list.- Comparison follows the id rules in
Manifest.md: an
idis case-insensitive throughout and is lowercased before it is stored or compared. commitis lowercase hex when present, and the filename uses its first 8 characters. FAT32 is case-insensitive, so the lowercasing rule matters twice: it prevents the id-derived prefix colliding on disk, and the commit suffix distinguishes installed versions without relying on case alone.- For
channel: local, which records no git commit, tooling uses the literallocalfilename suffix and MUST NOT keep more than one local install of the sameid.
Where each rule in this document is checked. Same three-part split as Manifest.md.
schema/edgetx-state.v1.json holds these:
| Rule | Fixture |
|---|---|
id, version, reason, source and requires are present |
missing-requires.yml |
reason is explicit or dependency |
bad-reason.yml |
source.commit is present unless channel is local |
missing-commit.yml |
source.commit is 40 or 64 lowercase hex characters |
abbreviated-commit.yml |
source.path is present when channel is local, and commit/ref are null there |
local-without-path.yml, local-with-commit.yml, local-with-ref.yml |
Remote sources do not record a host-local source.path, and channel: commit records no ref |
remote-with-local-path.yml, commit-with-ref.yml |
source.path is absolute and carries no control characters |
relative-local-path.yml, control-char-local-path.yml |
variant and source.manifest_path satisfy the path rules |
variant-path-escape.yml |
version is strict semver |
version-leading-zero-prerelease.yml |
requires[].version matches the manifest's range grammar, and requires[].commit is a full lowercase commit id when present |
requires-leading-zero-prerelease.yml |
source.ref is a legal git refname with no traversal |
ref-traversal.yml, ref-leading-dot-component.yml, ref-lock-component.yml, ref-trailing-dotdot.yml |
At most 64 requires entries per package-state file |
— |
Timestamps use the Z UTC designator |
timestamp-with-offset.yml |
A marker declares operation, package_id and started_at |
marker-missing-operation.yml |
A marker's operation is one of the three values |
marker-bad-operation.yml |
| Every file-list line satisfies the path rules | valid.list, valid-crlf.list, invalid-absolute.list, invalid-dotdot.list, invalid-backslash.list, invalid-reserved.list, invalid-dot-segment.list, invalid-line-separator.list |
| Rule | Why the schema cannot | Fixture |
|---|---|---|
No two package-state files share the same (id, commit) key |
Needs every state file and filename read together | duplicate-package-key-a.yml, duplicate-package-key-b.yml |
source.repo is the id or a prefix of it at a / boundary — necessary, not sufficient |
Repository depth varies by host, so no pattern can say where the repo ends, and none can compare two values in one document | repo-not-prefix-of-id.yml |
| No two file lists claim the same destination | Needs every list read together | — |
| Timestamp components are in range | Needs date arithmetic, not a pattern | — |
Normative and not expressible as a fixture: each is about what tooling does with the card, not about what a state file may contain. A behavioural suite driving a real implementation would cover them — see CONTRIBUTING.md.
| Rule | Stated in |
|---|---|
PKG/ is reserved; no content destination may target it |
Reserved namespace |
source.commit is recorded whenever it is known |
source |
| Ownership is per file; directories are never owned | Ownership |
| Every destination file is checked against other packages' lists before install | Ownership |
| An untracked file is overwritten only under an explicit policy | Ownership |
A non-empty directory is never deleted; pruning never rises above the card root or into PKG/ |
Ownership |
A written or deleted .lua takes its untracked .luac sibling with it, subject to the overwrite policy |
Bytecode companions |
A destination collides with an owned .lua/.luac counterpart |
Bytecode companions |
| The sibling policy is applied before the operation marker is written | Bytecode companions |
| More than 512 package-state files is refused | Resource limits |
| Blank lines in a file list are ignored; an invalid line is reported, not acted on | PKG/packages/… |
| State writes are replace-in-place | Durability |
| An unparseable state file is reported, never silently discarded | Durability |
| An operation marker is written before any change and removed after the final state write | Durability |
| A marker present at startup is reported as an unfinished operation | Durability |
| Operations are serialised within a process | Durability |
An orphaned reason: dependency package may be removed; an explicit one never is |
Orphan removal |
Removing a package never silently leaves another's requires unsatisfied |
Orphan removal |
| State with a newer MAJOR is never rewritten; anything up to the tooling's own is read | edgetx_format_version |
An id is lowercased before storing or comparing |
Package id as a key |
Update re-resolves from source.origin when it is set, not from repo |
source |
Installing a dependency package explicitly promotes it to explicit |
Orphan removal |
| A reconciliation command is offered — SHOULD, not MUST | Durability |
| Old state layouts are migrated or ignored, never silently half-read | Migration |
Three earlier layouts exist and none is this one:
- Pre-release drafts of this specification described
EDGETX/PKG/state/with a single globalfiles.yml. No tooling shipped against those drafts, so no migration is defined. Anything written by a draft implementation should be deleted and the packages reinstalled. - Shipped tooling stored
RADIO/packages.ymlwith per-package lists underRADIO/packages/. Tooling that supported that layout SHOULD, whenPKG/is absent and the old location is present, read the old state, write it toPKG/, and delete the old files. Fields the old layout did not record — notablyreasonandrequires— are set toexplicitand empty. - State format
1.0stored all installed packages inPKG/installed.yml, with per-package file lists inPKG/files/. Tooling that reads1.1SHOULD, whenPKG/packages/is absent andPKG/installed.ymlis present, split the old file into onePKG/packages/<package-key>.ymlper package and move each old list to the matching siblingPKG/packages/<package-key>.list. A remote package's<package-key>uses the first 8 characters of its recordedsource.commit; a local package uses thelocalsuffix.
If both PKG/packages/ and PKG/installed.yml are present, tooling SHOULD
prefer PKG/packages/ and report the legacy files as leftovers rather than
merging them silently.
Tooling that never supported any legacy layout MAY ignore it, in which case the user reinstalls their packages. Deleting stale state is safe: it forgets packages, it does not delete their files.