You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
mackas is structurally single-project. There is one root, one flat work/ that is KAS_WORK_DIR for everything, and one volume stem (oe-build) naming exactly three ext4 volumes. Building a second thing today means one of two bad options: a fully separate adopted root driven by --config <full path> on every single invocation, with its own private download cache; or sharing everything with the first project, including the flat work/, where kas's ordinary repos_checkout will silently reset one project's layer pins when the other builds — the same class of data loss the -k/--skip discipline exists to prevent, arriving through a path no --skip covers.
What is actually wanted:
Multiple projects with different volumes on one machine. meta-qcom and meta-ai side by side, each with its own TMPDIR, neither stepping on the other.
Optional sharing of downloads and sstate across projects, never concurrently. Sharing either cache must be available — it is neither a requirement of multi-project support nor a recommendation. Projects are fully private by default; a user who wants one DL_DIR, or one sstate store, opts in per project. A shared TMPDIR is never offered.
A UX that is not user-hostile. The project should be deduced from $PWD or from the location of the kas yaml files, not demanded as a flag on every command. In particular, hand-typed kas-container build meta-qcom/kas/base.yml:... — the primary real-world workflow — must land on the right volumes with no flag and no sourced environment.
Only a couple of pieces exist today. adopt already derives mackas-<name>-{tmp,dl,sstate} via adopt_unique_volume_name() and already writes a complete standalone config to ~/.config/mackas/projects/<name>.conf. Confirmed absent from the entire tree: MACKAS_VOLUME_DL_NAME, MACKAS_VOLUME_SSTATE_NAME, MACKAS_SSTATE_GROUP, mackas-shared-dl, cmd_build, and any --project global flag.
Full design reasoning (prior art comparisons, per-milestone rationale) lives in this issue and its sub-issues; nothing referenced here is off-GitHub.
The core architectural bet
Project identity is pinned once, explicitly. It is then selected implicitly, by structural matching.
Pinning happens only through a command the user types: mackas adopt <root> (exists) or the new mackas project add <name> (its in-root sibling). Both write the same artifact — a complete, standalone config at ~/.config/mackas/projects/<name>.conf.
Selection happens per invocation. With no explicit selector, physical $PWD (or the first path component of a kas chain argument) resolves to a project only when it lies inside a directory some already-pinned config points at.
Inference selects among identities the user already consented to; it never mints one. That is direnv's and mise's trust model with the consent moved to pin time, plus west's and repo's marker-based root discovery — and it makes the backward-compatibility argument hold by construction rather than by careful reasoning: a user with no pinned projects has nothing for inference to match, so every code path, file layout, volume name and KAS_WORK_DIR stays byte-identical to today.
The second half of the bet: "shared but not concurrently" needs no new mechanism at all. It is exactly what the one-VM-per-ext4-volume rule already enforces — once the held-volume refusal, a pre-existing invariant-3 gap on the build path, actually ships.
Two decisions from the earlier plan are carried forward verbatim because everything else rests on them:
--project / $MACKAS_PROJECT_SELECT is a config selector — it decides which single file load_config() sources — never a fifth precedence rung. defaults → config file → environment → --set is untouched; invariant 4 is literally unchanged. MACKAS_PROJECT_SELECT is deliberately not in SETTING_NAMES.
Volume identity keys off the selector, never off MACKAS_PROJECT_DIR. Every existing user already has MACKAS_PROJECT_DIR set, and item 25's _mackas_derive_project() sets it automatically for anyone driving kas by hand. Keying off it would silently relocate existing users' volumes mid-session. The selector surface is new, so keying off it cannot affect anyone who has not opted in.
The ~/oe/work rethink
A project workspace is work/<name>/, and it is that project's entire KAS_WORK_DIR — the config checkout and its kas-cloned layer siblings together, private to that project:
$MACKAS_ROOT/
work/
meta-ai/ # legacy checkout — still owned by the legacy (no-project) config
openembedded-core/ # legacy kas-cloned sibling — untouched
.repo-ref/ # shared git object store (KAS_REPO_REF_DIR)
meta-qcom/ # PROJECT WORKSPACE for pinned project "meta-qcom" = its KAS_WORK_DIR
meta-qcom/ # the config checkout (mounted at /repo)
poky/ meta-oe/ ... # kas-cloned layers, private to this project
logs/meta-qcom/ env-meta-qcom.sh macos-meta-qcom.yml
This dissolves the shared-work/ hazard structurally instead of by discipline. Key properties:
A first-level directory under work/ is a project workspace if and only if a pinned config for that name exists. Filesystem shape alone never decides anything. A legacy work/meta-ai/ with no pin is just a checkout, exactly as today. Consequence: project add <name> must refuse a name colliding with an existing work/ entry unless it is converting that very entry.
Clone duplication is paid in git objects once, via KAS_REPO_REF_DIR=$MACKAS_WORK/.repo-ref — kas's native, documented mechanism (validated at kas-container:242, forwarded rw as /repo-ref at :634, read by kas/context.py:88). It ships late because it is unverified over virtiofs. The design works without it, just fatter (~2–5 GB per project).
MACKAS_SHORT_LINK is unchanged.~/oe still points at the one root. Projects are subdirectories of a root's work/, not new roots; adopt remains the tool for genuinely separate physical roots, and adopted roots' projects participate in selection identically, because selection reads pinned configs and not the short link.
The work/meta-qcom/meta-qcom/ stutter when repo name equals project name is cosmetic and accepted — it is what makes the layout uniform.
Project identity and selection
Selector precedence, most explicit first, exactly one winner:
Default search path (~/.config/mackas/config, ~/.mackas.conf) — today's behavior, and the terminal fallback
The derivation rule, precisely: compute pwd -P (physical paths on both sides, which defuses the ~/oe symlink problem); enumerate ~/.config/mackas/projects/*.conf and grep, never source, MACKAS_ROOT out of each; form <root>/work/<name>; prefix-match. Zero matches falls through to tier 4 — today's behavior verbatim. Exactly one match selects it. More than one — only reachable pathologically, e.g. an adopted root nested inside another root's work/ — dies listing the candidates. Grep-not-source is load-bearing for invariant 5: no candidate executes during selection, and the single winner is sourced only after config_file_is_safe() passes, unlike the typed --config path which deliberately skips it. A derived path under a predictable, name-guessable location is an ambush surface, not a request.
Special case for the hand-typed flow: standing in work/ with a kas chain in hand, the first path component of the first colon-entry names the workspace — kas-container build meta-qcom/kas/base.yml:... derives project meta-qcomiff pinned, else derives nothing.
Why the wrapper gets this for free.bin/kas-container re-invokes mackas runtime-args as a fresh subprocess, which inherits cwd — so tier-3 derivation works inside it automatically. A hand-typed kas-container build ... from inside a pinned workspace gets that project's volumes with no flag, no sourced env, no shell function. This is the single biggest non-hostility win in the design and it covers the primary workflow.
Selection fails closed, always. The failure modes that matter:
Workspace renamed or moved after pinning → prefix match fails → fall to tier 4, and mackas projects shows the stale pin with a suggested repair. Identity is never re-minted from a new directory name — that is docker-compose's orphaned-volumes lesson, and this design refuses to repeat it.
cwd in an unpinned legacy checkout → no derivation. Not a project. Today's behavior exactly.
Nested candidate workspaces → multiple matches → die listing both; adopt additionally warns at pin time when it would create the nesting.
Chain spans sibling workspaces → derive nothing (existing deliberate behavior; the sibling is outside the /repo mount anyway).
Non-interactive invocation where derivation would matter but fails → die with the candidate list. Never prompt, never guess.
The residual risk is not a wrong guess but a stale correct one — a project pinned long ago and forgotten. Mitigation is a once-per-shell note naming the project, the tier it resolved from, and the three volumes, plus mackas status/mackas projects always showing the resolution.
Volume model
Volume
Unpinned (today)
Pinned project <name>
Override
tmp (/build)
oe-build-tmp
mackas-<name>-tmp — always private
MACKAS_VOLUME_NAME (stem)
dl (/downloads)
oe-build-dl
mackas-<name>-dl — private by default; MACKAS_VOLUME_DL_NAME=mackas-shared-dl opts into sharing
MACKAS_VOLUME_DL_NAME
sstate (/sstate)
oe-build-sstate
mackas-<name>-sstate — private by default; MACKAS_SSTATE_GROUP=<g> → mackas-sstate-<g>
MACKAS_VOLUME_SSTATE_NAME
Nothing is shared unless asked for. All three volumes are private to a project by default, so adding a second project never changes what the first one reads or writes. Sharing is offered for downloads and for sstate because both are genuinely safe to share — downloads are upstream tarballs and git mirrors keyed by name+checksum, so a bad tarball fails checksum verification at fetch time no matter who wrote it, and sstate is task-input-hash keyed. Safe to share is not the same as worth sharing: under the one-VM rule a shared volume is a contention point, so sharing trades disk against sibling projects queueing on each other. That trade belongs to the user, per project, which is what the knobs are for. Neither cache is shared by default and neither is recommended.
This declines bitbake-setup's sharing convention, knowingly.bitbake-setup's site.conf ships common-sstate=yes — shared sstate by default across Setups — and shares downloads by convention too. That is right on a Linux host, where concurrent builds against one cache are normal and sharing costs nothing but disk. It is wrong here: under the one-VM rule a shared volume means sibling projects queue on each other, a cost that simply does not exist upstream. bitbake-setup's "build trees always private" half transfers directly and is adopted; its shared-by-default half becomes an opt-in. The mirror addendum below is what makes that affordable — an HTTP mirror delivers cross-project hit rate without the contention, so declining shared-by-default costs much less than it otherwise would.
BB_HASHSERVE_DB_DIR = "${SSTATE_DIR}" already travels with the sstate volume, so a shared group shares its hash-equivalence DB coherently, and the one-VM rule serializes SQLite access. No new mechanism. New name knobs get the same no-spaces validation MACKAS_VOLUME_NAME has, because they land unquoted in the word-split --runtime-args string.
Enforcement of "not concurrently" is the existing invariant, finally enforced on the build path: before any command that mounts volumes, resolve the three names, check each against running containers, refuse a held one — naming the holder and, by reverse-grepping the pinned configs, which project holds it ("mackas-shared-dl is mounted by a running build of project meta-ai"). Sharing makes collisions likelier and changes nothing about their handling. ro remains explicitly not an escape hatch.
Destruction safety.mackas destroy under a project selector destroys only that project's private volumes. A shared volume is refused with the list of pinned projects referencing it and requires the explicit mackas volume destroy <name> form. Same rule for the clean variants that touch dl/sstate — do_cleanall/do_cleansstate against a shared cache is a documented upstream footgun, and while the one-VM rule means there is no concurrent victim, a clean can still delete artifacts another project relies on.
Migration — two supported populations, nobody is forced to move:
(A) Do nothing. Stays on oe-build-* indefinitely. Fully supported, not deprecated.
(B) Become a pinned project.mackas project add <name> --from work/<checkout> converts in place, asking the one question that matters at pin time: keep oe-build-* (a supported end state, no data moves) or migrate to mackas-<name>-*. Nothing is ever renamed or moved silently.
The sharing knobs are deliberately not a migration route. MACKAS_VOLUME_DL_NAME does work standalone from the first milestone, so someone who has already decided they want one download cache can set it and seed via volume duplicate — but that is an opt-in for a user who went looking for it, not a path anyone should be walked down.
Addendum: two-way sstate mirroring
Multi-project makes the mirror story more important, because volume sharing has a structural ceiling: a shared sstate volume serializes builds. An HTTP mirror is a second sharing plane with the opposite properties — plain concurrent GETs, so N projects (or N machines) consume one warm cache simultaneously with zero volume contention. The two compose: a project keeps its fast private mackas-<name>-sstate and still gets cross-project hits via SSTATE_MIRRORS.
The read direction is already shipped — do not rebuild it.mackas-mirrord serves sstate and downloads read-only over HTTP with path validation, a credential store, rate limiting and the CacheManager hot-object accelerator; MACKAS_USE_HTTP_MIRRORS=1 plus MACKAS_HTTP_MIRROR_SSTATE/_DL generate the fragment; mackas check live-probes reachability; NFS-vs-HTTP ambiguity is a hard die. What multi-project still needs here is small and mostly free: per-project mirror URLs are just settings in the standalone per-project configs, seeded at pin time. MACKAS_SSTATE_GROUP deliberately does not imply a mirror URL — a group names a volume, a URL names a network endpoint, usually on another machine; deriving one from the other is exactly the magic this design refuses. Default mirror layout is one flat shared tree (<mirror>/sstate/), matching the upstream public mirror's all/ convention, because hit rate is the entire point and the hash-safety analysis says a flat tree is safe.
The publish direction does not exist anywhere and is the new work: mackas sstate push.
Surface: a new sstate subcommand, not a sixth retrieve object. retrieve's contract is "copy build products out of the tmp volume for the user"; all five objects live in MACKAS_VOL_TMP and the result belongs to the user. sstate lives in its own volume and the goal is publication, not inspection. So fetch_tmp_subdir() generalizes to fetch_volume_subdir(<volume>, ...) — a parameter change, nothing in its mechanism is tmp-specific — and cmd_sstate() gains push beside prune. Reuse the machinery, don't expose the verb.
One push:
Resolve the sstate volume from the selector (private or group, both legitimate). volume_in_use → refuse.
Throwaway container mounts the volume read-only plus a host staging dir; copy only objects newer than the last-push stamp (host-side stamp per volume+destination pair, find -newer). A lost stamp means a full rescan — merely slow, never wrong, because of --ignore-existing. Self-healing.
Correction (2026-08-24): the "sstate objects are write-once" premise this originally rested on is false. bitbake touches an object's mtime every time it reuses it — sstate.bbclass's sstate_eventhandler and the setscene-valid check both call oe.utils.touch()/os.utime() on a hit, and mackas's own sstate prune is built entirely on that fact. So -newer selects everything the last build reused, not only what is new. The error direction is safe — it over-selects, never misses, and --ignore-existing makes the wire cost nil — but each over-selected object is still byte-copied into host staging and CRC-verified there, so a push is materially heavier than "incremental" implies, and staging must be sized for the build's reuse set rather than just its new objects. Anything better needs a signal other than mtime (a content index, or objects recorded at write time).
Run the existing manifest verification (retrieve_verify_script() in-container, retrieve_verify_local() on the host) over the staged copy. The volume is released here — the network transfer happens with nothing mounted.
Two-pass rsync --ignore-existing over ssh into the directory mackas-mirrord serves: pass 1 everything except *.siginfo, pass 2 the siginfo files.
Update the stamp only after rsync exits clean.
This resolves issue #1's "push from the volume or from a host-side copy" question: from a host-side copy. Three reasons in order: the hardened copy-with-verification path (chunked cksum manifests, source/dest comparison, tar fallback, hard die on double mismatch) exists only there, and it exists because a real >18 GB artifact was once copied with the right size and wrong content — publishing to a shared mirror is the last place to skip that; push credentials stay on the host, keeping the throwaway container network-free and credential-free; and pushing from inside the container would put a long network transfer inside the window where the volume is held.
Transport: rsync over ssh, and specifically not HTTP PUT. ccache/sccache push naked PUTs with no documented integrity story and get away with it only because hash-keyed consumers ignore bad entries. Bazel/REAPI and actions/cache run real two-phase protocols with the right properties, but need a protocol-speaking server. rsync-to-the-served-directory gets Bazel's properties without the server: --ignore-existing is a filesystem-native FindMissingBlobs (never re-upload, never overwrite — published objects are immutable, first committer wins), and rsync's default temp-file-then-rename gives per-object atomic visibility — the same same-directory-rename pattern sstate.bbclass itself uses. Never --inplace. Adding PUT to mackas-mirrord is rejected outright: read-only-ness is the security property being asked for. Write path authenticated out-of-band over ssh, read path anonymous and RO — that split is the design.
Two concurrent pushers need no locking: --ignore-existing makes it first-writer-wins, and two pushers racing on the same hash-derived path are pushing identical bytes anyway. The two-pass ordering guarantees a consumer never sees a .siginfo for an absent payload.
Prune ordering — taking the position #1 left open: push first, prune second. Prune-then-push buys nothing (--ignore-existing plus stamp-based staging already keeps pushes incremental) and costs the mirror objects it would have archived. Push-then-prune inverts the risk profile of the whole aging problem: once an object is on the mirror, pruning it locally downgrades from "forced rebuild" to "HTTP refetch". That also defuses the items-36/45 objection to sstate_prune()'s wall-clock cutoff — against a pushed volume an over-aggressive cutoff is a performance bug, not a correctness one — so the relative-cutoff fix stops gating the push path.
Trigger:mackas sstate push ships explicit. MACKAS_SSTATE_PUSH_AUTO=1 (after a successful build) comes later, matching every CI precedent, with the difference that mackas adds the staging verification those pipelines lack.
Milestones
All work for this epic targets the multi-project branch, not main. Milestones PR into that integration branch and it merges to main once the epic is ready, so a half-landed epic never sits on main. CI covers both: pull_request is unfiltered so every milestone PR is gated, and multi-project is in the push trigger so the integration branch gets a post-merge run too.
Tracked as sub-issues of this epic. The project track is strictly ordered; the mirror track depends only on M1 and is severable — multi-project is what makes retrieve-and-push important, not what makes it possible, so it can run in parallel or even ship first.
#82 (M8)closed in favour of #100 — seeding by clone dissolves the sharing groups it specified; its machine-wide disk accounting is folded into #100.
Mirror track (depends only on #75/M1, otherwise independent): #83 (MP1) → #84 (MP2) / #85 (MP3)
Transition UX (needs #76/M2 and #77/M3; lands alongside #79/M5): #90 — env.sh project selector when derivation cannot resolve, re-derived on cwd change
Volume lifecycle (reshapes #82; raises the priority of #91): #100 — on-demand project volumes: lazily created, kept, optionally seeded by clone
Resource safety (needs #77/M3, since parallel builds only become normal once volumes are private): #91 — refuse a build when the host has no CPU/RAM headroom for a second project
See each sub-issue for scope, size, and risk; blocking relationships are set natively on each issue.
Open questions
Genuinely undecided, as distinct from everything above, which the design commits to:
KAS_REPO_REF_DIR over virtiofs is unverified. The whole "per-project workspaces without duplicating poky-sized clones" story rests on it. Fallback is full per-project clones — safe, ~2–5 GB extra each. Decide at M6 with data in hand, not before.
Selector variable naming.Decided: MACKAS_PROJECT_SELECT. The selector is now --project <name> / $MACKAS_PROJECT_SELECT. The rejected option was reusing MACKAS_PROJECT_NAME, which reads as a sibling of the derived, informationalMACKAS_PROJECT / MACKAS_PROJECT_DIR that _mackas_derive_project() exports — and the distinction between those two things is load-bearing, not cosmetic: volume identity keys off the selector and must never key off MACKAS_PROJECT_DIR, which every existing user already has set. A name that blurs the two invites exactly the confusion the whole backward-compatibility argument depends on avoiding. _SELECT says what it does — it chooses a config file — and cannot be mistaken for a description of the project. Decided before M2 (M2: The project selector, read-only #76) ships it, while the cost is zero: the name appears in no code, test or doc yet.
Standalone per-project configs vs. layering. The design accepts repeating machine-wide preferences per project file to keep the precedence chain at four rungs; project add mitigates it by seeding from effective settings. A global-then-project source order is genuinely more convenient and genuinely a fifth rung. If config drift across many projects becomes painful, that is a future invariant-amendment discussion — not something to smuggle in.
sstate default: private vs. shared.Decided: private, and so are downloads. Prior art (bitbake-setup's common-sstate=yes) leans shared, but that reasoning assumes a host where concurrent builds against one cache are normal; the one-VM rule makes any shared volume a contention point. Both caches are therefore private by default and sharing is opt-in per project — not required, not recommended. The mirror addendum is what makes this cheap, delivering the hit rate that was the main argument for sharing without the contention that was the argument against. What remains genuinely open is narrower: whether project add should mention the sharing knobs at pin time, or leave them to the docs.
TOCTOU on the held-volume check. A flock on container-volumes/<vol>.lock would close the window between two invocations starting simultaneously. Cost: another state file that goes stale after a crash and needs repair handling. Deferred as hardening; the single-user Mac reality makes the window mostly theoretical.
Shared-cache garbage collection. A long-lived mackas-shared-dl / mackas-sstate-<g> only grows. Whether a volume prune-style aging policy is needed, and on what signal (atime is unreliable on ext4-in-image), is unresolved. The existing per-volume sstate-prune machinery may simply suffice when pointed at shared names.
Disk budget. ~680 GB worst case for four projects with private tmp/sstate plus shared dl remains unmeasured. M8's aggregate accounting produces the number; nothing commits to a budget before then. volume resize grows a volume now, so a conservative cap is no longer a one-way door.
Should mackas set follow derivation? The design says yes, with the written file echoed on every set. The conservative alternative — require an explicit --project for any write — trades a small surprise risk for friction on the most common tweak. Flagged because it is the one place a derived selection writes rather than reads.
Flat shared mirror tree vs. per-group subtrees as the default. Flat maximizes hit rate and is hash-safe; per-group mirrors the private-by-default philosophy and keeps one project's container-image-version fragmentation out of another's listing. Cheap to change before anyone depends on the layout, expensive after.
Cross-machine hash equivalence.BB_HASHSERVE_DB_DIR = "${SSTATE_DIR}" travels with the volume, so same-machine consumers share equivalence state — but a mirror consumer on another machine has its own DB and misses OEEquivHash-mediated hits. Upstream pairs SSTATE_MIRRORS with BB_HASHSERVE_UPSTREAM; whether the mirror host should also run bitbake-hashserv, and whether kas-in-container can reach it cleanly over vmnet NAT, is unverified and could materially cap the cross-machine hit rate.
Staging disk cost. Stage-then-verify-then-push doubles the transient footprint of new objects. A streaming tar | ssh path would eliminate it but forfeits the compare-two-manifests-then-retry structure that caught real corruption. Measure first-push sizes before optimizing.
Mirror-side GC. The mirror only grows, and it lives on a host the throwaway-container shape doesn't reach. Options: a prune mode in mackas-mirrord, a cron'd find on the mirror host, or explicitly out of scope. Overlaps Resilient caching bridge for sstate/downloads mirrors on unreliable network links #38's sqlite last-access work, which would supply exactly the usage data flat mtime lacks — the two should land coherently.
Downloads push (MP3) is deferred only for sequencing, not because anything is unresolved.
Push after a failed build. Completed tasks' sstate objects are individually valid regardless of overall outcome, so pushing them is hash-safe in principle. Auto-push gates on success (every CI precedent does). Whether an explicit sstate push against a failed build's volume should proceed silently or warn is a small UX call left open.
Stamp semantics with multiple destinations. One stamp per volume+destination pair handles the obvious case; whether anyone genuinely pushes one volume to two mirrors is unknown. Costless to defer — the stamp is keyed, not global.
In progress. Design researched against real-world kas usage and workspace-tool prior art (west, repo, direnv, mise, docker-compose, bitbake-setup, plus ccache/sccache, Bazel REAPI, actions/cache and the upstream autobuilder for the push side), grounded against the real mackas source and the pinned kas upstream, and reviewed for backward compatibility against the existing config-precedence invariant — which this design leaves literally unchanged.
Implementation is underway on the multi-project branch. Landed and merged: M0 (#74), M1 (#75), M2 (#76), MP1 (#83). M8 (#82) is closed as superseded, not shipped — its scope (sharing-group seeding) was folded into #100, which is still open. M3–M7 and MP2–MP3 remain unimplemented.
An independent review of what's landed (2026-09-02) found three bugs, filed as #106 (set/unset lockout on an invalid volume-name setting), #107 (sstate push's incremental stamp uses the host clock against build-VM mtimes, so clock skew silently drops objects from future pushes), and #108 (SKILL.md's --skip decision procedure probes git log @{u}..HEAD, which breaks on detached HEAD / no upstream — a state kas commonly leaves repos in).
The problem
mackas is structurally single-project. There is one root, one flat
work/that isKAS_WORK_DIRfor everything, and one volume stem (oe-build) naming exactly three ext4 volumes. Building a second thing today means one of two bad options: a fully separate adopted root driven by--config <full path>on every single invocation, with its own private download cache; or sharing everything with the first project, including the flatwork/, where kas's ordinaryrepos_checkoutwill silently reset one project's layer pins when the other builds — the same class of data loss the-k/--skipdiscipline exists to prevent, arriving through a path no--skipcovers.What is actually wanted:
DL_DIR, or one sstate store, opts in per project. A shared TMPDIR is never offered.$PWDor from the location of the kas yaml files, not demanded as a flag on every command. In particular, hand-typedkas-container build meta-qcom/kas/base.yml:...— the primary real-world workflow — must land on the right volumes with no flag and no sourced environment.Only a couple of pieces exist today.
adoptalready derivesmackas-<name>-{tmp,dl,sstate}viaadopt_unique_volume_name()and already writes a complete standalone config to~/.config/mackas/projects/<name>.conf. Confirmed absent from the entire tree:MACKAS_VOLUME_DL_NAME,MACKAS_VOLUME_SSTATE_NAME,MACKAS_SSTATE_GROUP,mackas-shared-dl,cmd_build, and any--projectglobal flag.Full design reasoning (prior art comparisons, per-milestone rationale) lives in this issue and its sub-issues; nothing referenced here is off-GitHub.
The core architectural bet
Project identity is pinned once, explicitly. It is then selected implicitly, by structural matching.
mackas adopt <root>(exists) or the newmackas project add <name>(its in-root sibling). Both write the same artifact — a complete, standalone config at~/.config/mackas/projects/<name>.conf.$PWD(or the first path component of a kas chain argument) resolves to a project only when it lies inside a directory some already-pinned config points at.Inference selects among identities the user already consented to; it never mints one. That is direnv's and mise's trust model with the consent moved to pin time, plus west's and repo's marker-based root discovery — and it makes the backward-compatibility argument hold by construction rather than by careful reasoning: a user with no pinned projects has nothing for inference to match, so every code path, file layout, volume name and
KAS_WORK_DIRstays byte-identical to today.The second half of the bet: "shared but not concurrently" needs no new mechanism at all. It is exactly what the one-VM-per-ext4-volume rule already enforces — once the held-volume refusal, a pre-existing invariant-3 gap on the build path, actually ships.
Two decisions from the earlier plan are carried forward verbatim because everything else rests on them:
--project/$MACKAS_PROJECT_SELECTis a config selector — it decides which single fileload_config()sources — never a fifth precedence rung.defaults → config file → environment → --setis untouched; invariant 4 is literally unchanged.MACKAS_PROJECT_SELECTis deliberately not inSETTING_NAMES.MACKAS_PROJECT_DIR. Every existing user already hasMACKAS_PROJECT_DIRset, and item 25's_mackas_derive_project()sets it automatically for anyone driving kas by hand. Keying off it would silently relocate existing users' volumes mid-session. The selector surface is new, so keying off it cannot affect anyone who has not opted in.The
~/oe/workrethinkA project workspace is
work/<name>/, and it is that project's entireKAS_WORK_DIR— the config checkout and its kas-cloned layer siblings together, private to that project:This dissolves the shared-
work/hazard structurally instead of by discipline. Key properties:work/is a project workspace if and only if a pinned config for that name exists. Filesystem shape alone never decides anything. A legacywork/meta-ai/with no pin is just a checkout, exactly as today. Consequence:project add <name>must refuse a name colliding with an existingwork/entry unless it is converting that very entry.KAS_REPO_REF_DIR=$MACKAS_WORK/.repo-ref— kas's native, documented mechanism (validated atkas-container:242, forwarded rw as/repo-refat:634, read bykas/context.py:88). It ships late because it is unverified over virtiofs. The design works without it, just fatter (~2–5 GB per project).MACKAS_SHORT_LINKis unchanged.~/oestill points at the one root. Projects are subdirectories of a root'swork/, not new roots;adoptremains the tool for genuinely separate physical roots, and adopted roots' projects participate in selection identically, because selection reads pinned configs and not the short link.work/meta-qcom/meta-qcom/stutter when repo name equals project name is cosmetic and accepted — it is what makes the layout uniform.Project identity and selection
Selector precedence, most explicit first, exactly one winner:
--config <path>/--project <name>(mutually exclusive)$MACKAS_CONF/$MACKAS_PROJECT_SELECT(mutually exclusive)$PWD(new)~/.config/mackas/config,~/.mackas.conf) — today's behavior, and the terminal fallbackThe derivation rule, precisely: compute
pwd -P(physical paths on both sides, which defuses the~/oesymlink problem); enumerate~/.config/mackas/projects/*.confand grep, never source,MACKAS_ROOTout of each; form<root>/work/<name>; prefix-match. Zero matches falls through to tier 4 — today's behavior verbatim. Exactly one match selects it. More than one — only reachable pathologically, e.g. an adopted root nested inside another root'swork/— dies listing the candidates. Grep-not-source is load-bearing for invariant 5: no candidate executes during selection, and the single winner is sourced only afterconfig_file_is_safe()passes, unlike the typed--configpath which deliberately skips it. A derived path under a predictable, name-guessable location is an ambush surface, not a request.Special case for the hand-typed flow: standing in
work/with a kas chain in hand, the first path component of the first colon-entry names the workspace —kas-container build meta-qcom/kas/base.yml:...derives projectmeta-qcomiff pinned, else derives nothing.Why the wrapper gets this for free.
bin/kas-containerre-invokesmackas runtime-argsas a fresh subprocess, which inherits cwd — so tier-3 derivation works inside it automatically. A hand-typedkas-container build ...from inside a pinned workspace gets that project's volumes with no flag, no sourced env, no shell function. This is the single biggest non-hostility win in the design and it covers the primary workflow.Selection fails closed, always. The failure modes that matter:
mackas projectsshows the stale pin with a suggested repair. Identity is never re-minted from a new directory name — that is docker-compose's orphaned-volumes lesson, and this design refuses to repeat it.adoptadditionally warns at pin time when it would create the nesting./repomount anyway).The residual risk is not a wrong guess but a stale correct one — a project pinned long ago and forgotten. Mitigation is a once-per-shell note naming the project, the tier it resolved from, and the three volumes, plus
mackas status/mackas projectsalways showing the resolution.Volume model
<name>/build)oe-build-tmpmackas-<name>-tmp— always privateMACKAS_VOLUME_NAME(stem)/downloads)oe-build-dlmackas-<name>-dl— private by default;MACKAS_VOLUME_DL_NAME=mackas-shared-dlopts into sharingMACKAS_VOLUME_DL_NAME/sstate)oe-build-sstatemackas-<name>-sstate— private by default;MACKAS_SSTATE_GROUP=<g>→mackas-sstate-<g>MACKAS_VOLUME_SSTATE_NAMENothing is shared unless asked for. All three volumes are private to a project by default, so adding a second project never changes what the first one reads or writes. Sharing is offered for downloads and for sstate because both are genuinely safe to share — downloads are upstream tarballs and git mirrors keyed by name+checksum, so a bad tarball fails checksum verification at fetch time no matter who wrote it, and sstate is task-input-hash keyed. Safe to share is not the same as worth sharing: under the one-VM rule a shared volume is a contention point, so sharing trades disk against sibling projects queueing on each other. That trade belongs to the user, per project, which is what the knobs are for. Neither cache is shared by default and neither is recommended.
This declines bitbake-setup's sharing convention, knowingly.
bitbake-setup'ssite.confshipscommon-sstate=yes— shared sstate by default across Setups — and shares downloads by convention too. That is right on a Linux host, where concurrent builds against one cache are normal and sharing costs nothing but disk. It is wrong here: under the one-VM rule a shared volume means sibling projects queue on each other, a cost that simply does not exist upstream. bitbake-setup's "build trees always private" half transfers directly and is adopted; its shared-by-default half becomes an opt-in. The mirror addendum below is what makes that affordable — an HTTP mirror delivers cross-project hit rate without the contention, so declining shared-by-default costs much less than it otherwise would.BB_HASHSERVE_DB_DIR = "${SSTATE_DIR}"already travels with the sstate volume, so a shared group shares its hash-equivalence DB coherently, and the one-VM rule serializes SQLite access. No new mechanism. New name knobs get the same no-spaces validationMACKAS_VOLUME_NAMEhas, because they land unquoted in the word-split--runtime-argsstring.Enforcement of "not concurrently" is the existing invariant, finally enforced on the build path: before any command that mounts volumes, resolve the three names, check each against running containers, refuse a held one — naming the holder and, by reverse-grepping the pinned configs, which project holds it ("
mackas-shared-dlis mounted by a running build of project meta-ai"). Sharing makes collisions likelier and changes nothing about their handling.roremains explicitly not an escape hatch.Destruction safety.
mackas destroyunder a project selector destroys only that project's private volumes. A shared volume is refused with the list of pinned projects referencing it and requires the explicitmackas volume destroy <name>form. Same rule for thecleanvariants that touch dl/sstate —do_cleanall/do_cleansstateagainst a shared cache is a documented upstream footgun, and while the one-VM rule means there is no concurrent victim, a clean can still delete artifacts another project relies on.Migration — two supported populations, nobody is forced to move:
oe-build-*indefinitely. Fully supported, not deprecated.mackas project add <name> --from work/<checkout>converts in place, asking the one question that matters at pin time: keepoe-build-*(a supported end state, no data moves) or migrate tomackas-<name>-*. Nothing is ever renamed or moved silently.The sharing knobs are deliberately not a migration route.
MACKAS_VOLUME_DL_NAMEdoes work standalone from the first milestone, so someone who has already decided they want one download cache can set it and seed viavolume duplicate— but that is an opt-in for a user who went looking for it, not a path anyone should be walked down.Addendum: two-way sstate mirroring
Multi-project makes the mirror story more important, because volume sharing has a structural ceiling: a shared sstate volume serializes builds. An HTTP mirror is a second sharing plane with the opposite properties — plain concurrent GETs, so N projects (or N machines) consume one warm cache simultaneously with zero volume contention. The two compose: a project keeps its fast private
mackas-<name>-sstateand still gets cross-project hits viaSSTATE_MIRRORS.The read direction is already shipped — do not rebuild it.
mackas-mirrordserves sstate and downloads read-only over HTTP with path validation, a credential store, rate limiting and theCacheManagerhot-object accelerator;MACKAS_USE_HTTP_MIRRORS=1plusMACKAS_HTTP_MIRROR_SSTATE/_DLgenerate the fragment;mackas checklive-probes reachability; NFS-vs-HTTP ambiguity is a harddie. What multi-project still needs here is small and mostly free: per-project mirror URLs are just settings in the standalone per-project configs, seeded at pin time.MACKAS_SSTATE_GROUPdeliberately does not imply a mirror URL — a group names a volume, a URL names a network endpoint, usually on another machine; deriving one from the other is exactly the magic this design refuses. Default mirror layout is one flat shared tree (<mirror>/sstate/), matching the upstream public mirror'sall/convention, because hit rate is the entire point and the hash-safety analysis says a flat tree is safe.The publish direction does not exist anywhere and is the new work:
mackas sstate push.Surface: a new
sstatesubcommand, not a sixthretrieveobject.retrieve's contract is "copy build products out of the tmp volume for the user"; all five objects live inMACKAS_VOL_TMPand the result belongs to the user. sstate lives in its own volume and the goal is publication, not inspection. Sofetch_tmp_subdir()generalizes tofetch_volume_subdir(<volume>, ...)— a parameter change, nothing in its mechanism is tmp-specific — andcmd_sstate()gainspushbesideprune. Reuse the machinery, don't expose the verb.One push:
Resolve the sstate volume from the selector (private or group, both legitimate).
volume_in_use→ refuse.Throwaway container mounts the volume read-only plus a host staging dir; copy only objects newer than the last-push stamp (host-side stamp per volume+destination pair,
find -newer). A lost stamp means a full rescan — merely slow, never wrong, because of--ignore-existing. Self-healing.Correction (2026-08-24): the "sstate objects are write-once" premise this originally rested on is false. bitbake touches an object's mtime every time it reuses it —
sstate.bbclass'ssstate_eventhandlerand the setscene-valid check both calloe.utils.touch()/os.utime()on a hit, and mackas's ownsstate pruneis built entirely on that fact. So-newerselects everything the last build reused, not only what is new. The error direction is safe — it over-selects, never misses, and--ignore-existingmakes the wire cost nil — but each over-selected object is still byte-copied into host staging and CRC-verified there, so a push is materially heavier than "incremental" implies, and staging must be sized for the build's reuse set rather than just its new objects. Anything better needs a signal other than mtime (a content index, or objects recorded at write time).Run the existing manifest verification (
retrieve_verify_script()in-container,retrieve_verify_local()on the host) over the staged copy. The volume is released here — the network transfer happens with nothing mounted.Two-pass
rsync --ignore-existingover ssh into the directorymackas-mirrordserves: pass 1 everything except*.siginfo, pass 2 the siginfo files.Update the stamp only after rsync exits clean.
This resolves issue #1's "push from the volume or from a host-side copy" question: from a host-side copy. Three reasons in order: the hardened copy-with-verification path (chunked
cksummanifests, source/dest comparison, tar fallback, harddieon double mismatch) exists only there, and it exists because a real >18 GB artifact was once copied with the right size and wrong content — publishing to a shared mirror is the last place to skip that; push credentials stay on the host, keeping the throwaway container network-free and credential-free; and pushing from inside the container would put a long network transfer inside the window where the volume is held.Transport: rsync over ssh, and specifically not HTTP PUT. ccache/sccache push naked PUTs with no documented integrity story and get away with it only because hash-keyed consumers ignore bad entries. Bazel/REAPI and actions/cache run real two-phase protocols with the right properties, but need a protocol-speaking server. rsync-to-the-served-directory gets Bazel's properties without the server:
--ignore-existingis a filesystem-native FindMissingBlobs (never re-upload, never overwrite — published objects are immutable, first committer wins), and rsync's default temp-file-then-rename gives per-object atomic visibility — the same same-directory-rename patternsstate.bbclassitself uses. Never--inplace. Adding PUT tomackas-mirrordis rejected outright: read-only-ness is the security property being asked for. Write path authenticated out-of-band over ssh, read path anonymous and RO — that split is the design.Two concurrent pushers need no locking:
--ignore-existingmakes it first-writer-wins, and two pushers racing on the same hash-derived path are pushing identical bytes anyway. The two-pass ordering guarantees a consumer never sees a.siginfofor an absent payload.Prune ordering — taking the position #1 left open: push first, prune second. Prune-then-push buys nothing (
--ignore-existingplus stamp-based staging already keeps pushes incremental) and costs the mirror objects it would have archived. Push-then-prune inverts the risk profile of the whole aging problem: once an object is on the mirror, pruning it locally downgrades from "forced rebuild" to "HTTP refetch". That also defuses the items-36/45 objection tosstate_prune()'s wall-clock cutoff — against a pushed volume an over-aggressive cutoff is a performance bug, not a correctness one — so the relative-cutoff fix stops gating the push path.Trigger:
mackas sstate pushships explicit.MACKAS_SSTATE_PUSH_AUTO=1(after a successful build) comes later, matching every CI precedent, with the difference that mackas adds the staging verification those pipelines lack.Milestones
All work for this epic targets the
multi-projectbranch, notmain. Milestones PR into that integration branch and it merges tomainonce the epic is ready, so a half-landed epic never sits onmain. CI covers both:pull_requestis unfiltered so every milestone PR is gated, andmulti-projectis in thepushtrigger so the integration branch gets a post-merge run too.Tracked as sub-issues of this epic. The project track is strictly ordered; the mirror track depends only on M1 and is severable — multi-project is what makes retrieve-and-push important, not what makes it possible, so it can run in parallel or even ship first.
Project track (strictly ordered, M3 is the backward-compatibility-critical milestone):
#74 (M0) → #75 (M1) → #76 (M2) → #77 (M3) → #78 (M4) → #79 (M5) / #80 (M6) → #81 (M7)
#82 (M8)closed in favour of #100 — seeding by clone dissolves the sharing groups it specified; its machine-wide disk accounting is folded into #100.Mirror track (depends only on #75/M1, otherwise independent):
#83 (MP1) → #84 (MP2) / #85 (MP3)
Transition UX (needs #76/M2 and #77/M3; lands alongside #79/M5):
#90 —
env.shproject selector when derivation cannot resolve, re-derived on cwd changeVolume lifecycle (reshapes #82; raises the priority of #91):
#100 — on-demand project volumes: lazily created, kept, optionally seeded by clone
Resource safety (needs #77/M3, since parallel builds only become normal once volumes are private):
#91 — refuse a build when the host has no CPU/RAM headroom for a second project
See each sub-issue for scope, size, and risk; blocking relationships are set natively on each issue.
Open questions
Genuinely undecided, as distinct from everything above, which the design commits to:
KAS_REPO_REF_DIRover virtiofs is unverified. The whole "per-project workspaces without duplicating poky-sized clones" story rests on it. Fallback is full per-project clones — safe, ~2–5 GB extra each. Decide at M6 with data in hand, not before.Selector variable naming.Decided:MACKAS_PROJECT_SELECT. The selector is now--project <name>/$MACKAS_PROJECT_SELECT. The rejected option was reusingMACKAS_PROJECT_NAME, which reads as a sibling of the derived, informationalMACKAS_PROJECT/MACKAS_PROJECT_DIRthat_mackas_derive_project()exports — and the distinction between those two things is load-bearing, not cosmetic: volume identity keys off the selector and must never key offMACKAS_PROJECT_DIR, which every existing user already has set. A name that blurs the two invites exactly the confusion the whole backward-compatibility argument depends on avoiding._SELECTsays what it does — it chooses a config file — and cannot be mistaken for a description of the project. Decided before M2 (M2: The project selector, read-only #76) ships it, while the cost is zero: the name appears in no code, test or doc yet.project addmitigates it by seeding from effective settings. A global-then-project source order is genuinely more convenient and genuinely a fifth rung. If config drift across many projects becomes painful, that is a future invariant-amendment discussion — not something to smuggle in.sstate default: private vs. shared.Decided: private, and so are downloads. Prior art (bitbake-setup'scommon-sstate=yes) leans shared, but that reasoning assumes a host where concurrent builds against one cache are normal; the one-VM rule makes any shared volume a contention point. Both caches are therefore private by default and sharing is opt-in per project — not required, not recommended. The mirror addendum is what makes this cheap, delivering the hit rate that was the main argument for sharing without the contention that was the argument against. What remains genuinely open is narrower: whetherproject addshould mention the sharing knobs at pin time, or leave them to the docs.flockoncontainer-volumes/<vol>.lockwould close the window between two invocations starting simultaneously. Cost: another state file that goes stale after a crash and needs repair handling. Deferred as hardening; the single-user Mac reality makes the window mostly theoretical.mackas-shared-dl/mackas-sstate-<g>only grows. Whether avolume prune-style aging policy is needed, and on what signal (atime is unreliable on ext4-in-image), is unresolved. The existing per-volume sstate-prune machinery may simply suffice when pointed at shared names.volume resizegrows a volume now, so a conservative cap is no longer a one-way door.mackas setfollow derivation? The design says yes, with the written file echoed on everyset. The conservative alternative — require an explicit--projectfor any write — trades a small surprise risk for friction on the most common tweak. Flagged because it is the one place a derived selection writes rather than reads.BB_HASHSERVE_DB_DIR = "${SSTATE_DIR}"travels with the volume, so same-machine consumers share equivalence state — but a mirror consumer on another machine has its own DB and misses OEEquivHash-mediated hits. Upstream pairsSSTATE_MIRRORSwithBB_HASHSERVE_UPSTREAM; whether the mirror host should also runbitbake-hashserv, and whether kas-in-container can reach it cleanly over vmnet NAT, is unverified and could materially cap the cross-machine hit rate.tar | sshpath would eliminate it but forfeits the compare-two-manifests-then-retry structure that caught real corruption. Measure first-push sizes before optimizing.mackas-mirrord, a cron'd find on the mirror host, or explicitly out of scope. Overlaps Resilient caching bridge for sstate/downloads mirrors on unreliable network links #38's sqlite last-access work, which would supply exactly the usage data flat mtime lacks — the two should land coherently.sstate pushagainst a failed build's volume should proceed silently or warn is a small UX call left open.Relationship to existing issues
mackas-<project>-{tmp,dl,sstate}, withmackas-shared-dland sstate groups both available), its non-breaking-by-construction migration promise, and its held-volume-refusal requirement all survive — with one deliberate departure: First-class projects: build any layer's kas config, share downloads by convention #14 made shared downloads the default, and this epic does not. Sharing either cache is optional. What is superseded is First-class projects: build any layer's kas config, share downloads by convention #14's four-phase plan and its three "not decided" items: shared vs per-projectwork/is now decided (per-project, M6, gated on virtiofs verification); the disk question is now decided as far as it can be without measurement (fix the accounting in M8, keep the numbers folded into item 4); and the sstate-GC question is narrowed and relocated to open question 6 above. First-class projects: build any layer's kas config, share downloads by convention #14's own item-13 note is absorbed by M5. Suggest closing First-class projects: build any layer's kas config, share downloads by convention #14 in favour of this epic once the design is agreed, rather than maintaining two descriptions of one feature.retrieve-style host-side copy, not from the volume directly (three reasons, chief among them that the manifest-verification path exists only there and exists because of a real corruption incident); and push first, prune second, because pushing is what makes pruning safe rather than the reverse. It also adds what A tool to push sstate and downloads to remote (NFS/rsync) mirrors #1 left unspecified: the transport decision (rsync-over-ssh, and explicitly not an HTTP PUT intomackas-mirrord, whose read-only-ness is a property worth keeping), the integrity story, and the stamp-based incremental mechanism. A tool to push sstate and downloads to remote (NFS/rsync) mirrors #1 should stay the tracking issue for the publish work — milestones MP1–MP3 here are A tool to push sstate and downloads to remote (NFS/rsync) mirrors #1 — and its body is worth updating with the resolved answers. Note that MP1–MP3 depend only on M1 and work identically against an unpinnedoe-build-sstate, so A tool to push sstate and downloads to remote (NFS/rsync) mirrors #1 is not blocked on multi-project landing.Status
In progress. Design researched against real-world kas usage and workspace-tool prior art (west, repo, direnv, mise, docker-compose, bitbake-setup, plus ccache/sccache, Bazel REAPI, actions/cache and the upstream autobuilder for the push side), grounded against the real mackas source and the pinned kas upstream, and reviewed for backward compatibility against the existing config-precedence invariant — which this design leaves literally unchanged.
Implementation is underway on the
multi-projectbranch. Landed and merged: M0 (#74), M1 (#75), M2 (#76), MP1 (#83). M8 (#82) is closed as superseded, not shipped — its scope (sharing-group seeding) was folded into #100, which is still open. M3–M7 and MP2–MP3 remain unimplemented.An independent review of what's landed (2026-09-02) found three bugs, filed as #106 (
set/unsetlockout on an invalid volume-name setting), #107 (sstate push's incremental stamp uses the host clock against build-VM mtimes, so clock skew silently drops objects from future pushes), and #108 (SKILL.md's--skipdecision procedure probesgit log @{u}..HEAD, which breaks on detached HEAD / no upstream — a state kas commonly leaves repos in).