needs:
host:
- path: /opt/vendor-tool # absolute, or a symbolic atom
access: ro # ro | rw
mount: /vendor/tool # OPTIONAL: present it at this VFS point
when: [macos] # OPTIONAL platform filter
optional: true # absent at bind = skipped (probes only)
why: "the tool probes its install root at boot" # MANDATORY
ARCHITECTURE · 12
Declarative composition.
A tebako system fully resolves the needs of its payloads and its running configuration: what runs with what, where it mounts, and which host paths it may touch are all declared, all resolvable, and jail-safe by default. The mechanism is the docker-compose analogue, with named errors instead of surprises.
|
Note
|
The needs blocks, the deny default, the resolution algorithm, and image-layer compose are shipped. The record-mode generator and the bootstrap override channels are planned and phased. |
Five documents, one owner each
Every fact lives in exactly one row. A slice’s needs are never re-authored into the composition; they resolve from the slice’s own declaration. The operator layers may tighten or add grants, and they may not edit a slice’s declaration.
| # | DOCUMENT | OWNER | ANSWERS | CONSUMERS |
|---|---|---|---|---|
D1 |
|
the slice’s feedstock |
which host surface the slice needs, and why |
resolver (via the registry mirror) · press (reads the image) |
D2 |
|
the composition author |
what runs with what, where, and how visible |
shim dispatch · tebako run · tebako press |
D3 |
|
tebako press (baked) |
the resolved needs of everything stitched |
the bootstrap at run time |
D4 |
|
the operator |
the defaults, the registries, and per-invocation tightening |
shim · bootstrap |
D5 |
|
the invoker |
a one-shot D2 row |
tebako run |
D1 — a slice declares its needs
The in-image manifest’s needs: block is the only spelling of host-surface
requirements: one key, one grammar, one semantics. Every entry is absolute,
or a symbolic atom ($HOME, $TMPDIR, $CWD, $TEBAKO_HOME) that is
resolved at bind, per invocation, and never baked; each entry carries
ro|rw, an optional bind-mount spelling, an optional platform filter, and
an optional: true marker for probe artifacts, and it carries a mandatory
why:.
The mechanism is fail-closed by construction: a need absent without the
optional marker is a named error; an ro ancestor may not hide an rw
descendant; and rw on a user-surface atom requires the composition’s
explicit consent, so declared write access is visible by construction.
Deriving a grant derives its strict ancestors as traverse reads, so
canonicalization walks pass (the JVM’s realpath-at-boot case).
D2 — the composition document
The composition document is tebako.yaml, YAML against a versioned JSON
Schema, discovered by walking up from the cwd or named with -f. The CLI
flags are this document written inline: tebako run accepts
--slice name@req, --runtime, --entrypoint, --mount
host:mount:ro|rw, --policy open|deny, and --need path:ro; each flag
maps 1:1 onto a key, flags and -f compose, and flags win.
version: 1
runtime:
name: ruby
requirement: "~> 3.3"
slices:
- { name: metanorma, requirement: ">= 2.1" }
- { name: openjdk, requirement: "21" }
entrypoint: mnconvert
policy: deny # the DEFAULT once anything is declared
mounts: # operator bind-mounts (docker -v)
- { host: ./data, mount: /data, access: ro }
needs: # composition-level additions, D1 grammar
host:
- { path: "$HOME/.fontist", access: rw, why: "fontist cache" }
The default policy is jail-safe
|
Note
|
This capability is shipped. |
Any declaration in force, a composition document, a slice with needs, or a
CLI flag, turns the policy default to deny, and open must be asked for
by name. A run with no declarations anywhere runs open (never_denies),
because declarations are what turn the jail on. Under deny, the effective
world is exactly the VFS images (read-only by construction and not
policy-gated at all), the platform floor, the system’s own surface
($TEBAKO_HOME and the exec cache, read-only at bind), the union of
declared needs, the composition’s mounts/needs, and argument files
auto-granted ro. Everything else answers EPERM/EROFS and is journaled; the
journal line names the covering declaration (would-need=slice:openjdk) or
states that none exists, so the operator learns the exact key to add from
the denial itself.
The resolution algorithm
One algorithm serves three front ends (shim, bootstrap, and tebako
run/press), in order:
1. slice set the entrypoint slice + transitive depends: (at resolved versions) + the runtime
data slices join the mounts; they declare no needs (a needs block = named error)
2. needs union every slice's D1 needs.host, platform-filtered, atoms resolved at bind time
3. compose effective = needs-union ∪ mounts ∪ operator-grants, then operator tightening
(policy, --no-host, explicit denies) intersects — declarations request, the
operator tightens
4. needs-check every declared need verified against the effective policy; an uncovered need is
a NAMED RESOLUTION FAILURE before exec:
"slice <name> needs <path> (<access>, why: <why>) — denied by <source>"
5. export the authored part serializes to TEBAKO_JAIL (+_SOURCE, +_JOURNAL); the floor and
the system surface are NOT serialized — every bind re-derives them, so a spawned
child enforces exactly its parent's policy
| COMBINATION | RESOLVER | NEEDS SOURCES | POLICY DEFAULT |
|---|---|---|---|
|
— (resolved, never the composer) |
its release manifest |
n/a |
|
— (resolved) |
its D1 |
n/a |
|
— (resolved) |
none permitted (a needs block is a named error) |
n/a |
standalone package |
bootstrap |
D3 baked union ∪ lean runtime manifest ∪ operator env |
deny when D3 declares anything |
managed dispatch |
shim |
slice mirrors ∪ runtime manifest ∪ D2/D4/D5 |
deny when anything is declared |
|
CLI front end |
D2/D5 ∪ slice mirrors |
deny when anything is declared |
spawned child (class E) |
inherits TEBAKO_JAIL; the preload re-binds |
the PARENT’s composition — a child never re-resolves |
the parent’s |
Carried versus shared: one pipeline
|
Note
|
Carried slots plus the shared runtime are shipped today. Run-time payload resolution plus lazy seeding is a planned amendment. |
Fat and lean are the same architecture
(the locked model):
press always resolves and locks the full closure, with every slice’s version
and sha256 recorded in the package manifest, and the only remaining question
is where the bytes live. A slice is carried (its bytes travel in a
trailer slot) or shared (resolved at first run into the machine store,
verified against the locked digest, and shared with every package that needs
the same slice). A self-contained package carries everything, with zero
network, an empty cache, and one file. A shared-runtime package carries
its payloads and shares the runtime, which is the shape packed-mn ships.
lean/fat survive as deprecated aliases with a named warning.
Carried slices seed the store lazily at first run, best-effort, tmp+rename,
and journaled, never blocking the run, so a self-contained download doubles
as a cache prime. Shared slices resolve by the locked digests and fail
closed: carry/shared changes where the bytes come from, never what runs.
Platform coverage is declared by the slice itself (platforms: universal or
an explicit triplet list), and the composition’s per-slice platforms: key
is an assertion that can narrow and never extend; a slice that cannot cover
the host triplet is a named error, never a silent fallback.
Discovery: the record mode
A slice author usually does not know the host surface that the executable
touches. policy: record (env form TEBAKO_JAIL=record) allows every host
check and journals it (event=jail-allow path=… op=…); nothing is denied,
so the workload cannot crash from policy during discovery, and spawned
children append to the same journal. The generator, tfs needs
--from-journal, folds the journal, denials included because a denial is an
unmet need, into a draft needs: block: atoms re-substituted,
floor/store/exec-cache excluded, strongest observed op winning, ancestor
collapse, and why: "TODO — observed: …" left for the author to replace.
The human gate is the point: the record shows the observed minimum, and the
author decides what production actually wants. The generator never edits a
manifest itself, and shipping record in force is a named lint warning.
Swap the composition, not the slice
Slice images are immutable and content-addressed, and a configuration change
never mutates or rebuilds one. In managed mode, the swap is an edit of
tebako.yaml. A standalone package’s baked composition is the default, and
an external document overrides it, with first hit winning: --compose, then
TEBAKO_COMPOSE, then a sidecar <package>.tebako.yaml. The override
replaces the baked policy, mounts, needs, and entrypoint; it can never
conjure slice content, because a slice the trailer does not carry is a named
error, and the trust checks apply to newly referenced slices exactly as at
press. The run’s provenance is journaled: event=composition
source=external(<path>) sha256=… or source=baked. Today
tfs exec --compose speaks the image-layer subset, and the bootstrap/shim
override channels arrive in Phase-R.
See also: Jails and host proxying · The driver contract · Where shared slices live.