18 Commits
Author SHA1 Message Date
mvschwarz 02d7c9e470 The persistence check cannot be silent any more
orch named the shape in one line: silence from that check meant BOTH "verified"
and "could not ask", so the two outcomes were indistinguishable — a check that
cannot fail, which is the same defect class as the one it was added to catch.

The query lives behind `if systemctl ... 2>/dev/null; then`, so a failed query
skipped the whole block and printed nothing. On a host with no user manager — a
container, or systemctl-present-but-unreachable, both already handled elsewhere in
this script — the run would look exactly like a verified one.

Three outcomes, three distinct lines, none of them silence: systemd agrees;
systemd disagrees (a reboot would start the wrong studio); or the query failed and
persistence is NOT VERIFIED, with the reason quoted. The last one says explicitly
that the unit FILE was checked and what remains unconfirmed is whether the manager
picked it up — a daemon-reload that did not take looks identical to success from
every runtime check.

Driven against four fake systemctl behaviours including the worst one: a non-zero
exit with NO message on stderr, which now reports "non-zero, no message" rather
than falling through quietly.

I had asked cloud-impl to watch this line on a real box to settle whether it
degrades quietly. Removing the ambiguity is better than asking someone to observe
it — the observation would still have to distinguish two silences.
2026-08-05 13:55:28 -07:00
mvschwarz 62b904161b One systemd unit per studio, so a second provision cannot eat the first
cloud-impl found this on a live box with a reboot scheduled and held, and it would
have eaten that reboot. The unit was named `openrig-studio.service` for every
studio, so provisioning a SECOND studio on the same box overwrote the FIRST one's
unit and daemon-reloaded it. The old process kept running.

WHY NOBODY WOULD NOTICE, and this is the part worth keeping: everything anyone
would check said fine. `is-active` reported active. The original port answered 200.
Both were measuring the OLD PROCESS. Only `show ExecStart` disagreed, and nothing
was looking at it. The damage stays invisible until a restart, at which point the
box starts the second studio and the first never comes back — on that box the live
studio was serving happily while its unit already pointed at a test studio that had
FAILED its own verification.

The unit name is now derived from STUDIO_DIR: stable across reprovisions of one
studio, distinct between different ones, and printed by the run. One box with
several studios is a normal thing to want — testing a provision beside a live one
is exactly how this was found.

A unit belonging to a DIFFERENT studio is REFUSED rather than overwritten, which is
the rule the port guard already applies to a foreign studio: refuse rather than
adopt. Rewriting our own unit stays allowed; that is an ordinary reprovision. The
legacy shared-name unit is retired only when it names THIS studio — when it names
another one it is left strictly alone and reported, because that is precisely the
studio this defect used to steal.

AND THE CHECK THAT WOULD HAVE CAUGHT IT, added: verify the DEFINITION against
intent rather than the runtime against hope. The written unit must name this
studio's dir and this run's port, and after enabling, systemd's own loaded
definition is compared too — a daemon-reload that did not take leaves the manager
holding the old one while every runtime check still looks healthy.

Driven against a fake HOME with five cases including the negative control that
reproduces the original loss: under the old shared name the first studio's
persistence is gone, which is what a reboot would have booted. Linux target,
Darwin host, so the block was driven directly rather than the provision run end
to end.
2026-08-05 13:51:08 -07:00
mvschwarz 6e0da5037c Presence is not meaning: three provisioning defects from a real VPS
cloud-impl found all three provisioning a real VPS, named the shared shape,
and reported two of them against their own code. They are the same error at three
layers: something EXISTS, so it is treated as valid, and the check cannot tell
"declared and incomplete" from "declared absent" or "present and unusable".

D11 — install-app.mjs refused a deliberate "no provider". `apps/workspace` declares
`provider: { required: false }` with a note saying it needs none, and the checker
read the KEY'S PRESENCE as a declaration: three failures against a manifest that
was documenting its own correctness. WORKSPACE was uninstallable on any box. A
provider is declared by a PACKAGE, not by a key existing. One shared predicate
rather than two edits, because two sites deciding one question is the drift shape
this repo has already paid for three times — reverting either site alone still
brings failures back, which is how I know both mattered.

D9 — the provider branch tested whether a lockfile EXISTS, not whether it WORKS,
and committed to `npm ci` on that basis. A stub lockfile locking nothing sailed
past the file test and died inside npm. Present-but-unusable is worse than absent:
absent fails where the branch already handles it. Now it tries the exact path,
SAYS the lockfile was unusable, and falls through. Verified against real npm and a
real stub lockfile, with a control proving npm ci genuinely refuses it.

D10 — dependencies were installed for every provider on disk regardless of which
apps were enabled, so a provider nobody asked for could stop the whole provision.

D10 IS ALSO A CORRECTION TO MY OWN CLAIM, and that is the part worth recording.
I shipped the curated four-app default saying "two providers, no ffmpeg" — in the
README, in the commit message, and in my report to orch. It was FALSE: the app list
narrowed, the provider install did not, so all seven providers' dependencies landed
anyway. I asserted a benefit I never measured, in the same change where I was
careful to log what the cap dropped. The claim is true now because this makes it
true, not because it was true then.

Resolution walks JSON in node and is read line by line, never word-split. It
covers both ways an app needs a backend: naming one, and REQUIRING a verb somebody
else serves — `calls` is provider-agnostic by design, so an unserved required call
means reconciliation refuses the install. Optional calls pull nothing, mirroring
the ladder in app-manifest.md where only `required: true` may start a provider no
app names as its own.

298 tests. The shell paths were driven directly against fixtures and real npm;
this targets Linux and the host is Darwin, so the provision was not run end to end.
2026-08-05 13:34:59 -07:00
mvschwarz 57efab56b5 A bare host gets four apps, not thirteen
Removing the hardcoded list made provisioning install whatever the apps repository
happened to ship — 13 apps and 7 providers on a fresh box, including a video
toolchain that wants ffmpeg and a canvas needing a licence the user has to go and
acquire. A first boot should show someone the studio works, not hand them every
dependency the ecosystem has ever accumulated.

The default is workspace, rig-designer, factory, files. Two providers, no ffmpeg,
nothing to go and get. Two of the four need no provider at all, and the other two
ride the host and factory backends — so the set demonstrates the actual product
(see your rig, drive it, browse the box, work) at the lowest possible install cost.

NOT a pinned list, which is the failure the hardcoded one already had: the apps
repository moves on its own schedule, so a provisioner asserting yesterday's names
breaks a box that did nothing wrong. The curated set is INTERSECTED with what the
clone actually ships, and a curated name that has been renamed or dropped is
reported and skipped rather than fatal.

NO SILENT CAP. The run prints what it did not install and how to ask for it.
A default that quietly installs four of thirteen looks identical to one that
installed everything, and the person who wanted canvas would have no idea it was a
decision rather than a bug. `APPS="..."` selects exactly; `APPS=all` restores
install-everything.

All five paths driven against a fake apps repository rather than reasoned about:
curated default (4 of 13), the skipped list naming what was left out, APPS=all
(13), an explicit list obeyed verbatim, and a curated app the repo dropped being
named as missing while the other three still install.

This targets Linux and this host is Darwin, so the selection block was driven
directly rather than the provision run end to end. Stated because it bounds the
claim.
2026-08-05 06:11:30 -07:00
mvschwarz ab05910005 A failing provider install now says why
`npm ci --prefix ... --silent` suppresses npm's ERRORS as well as its progress.
The step runs under `set -euo pipefail`, so a failed provider install aborted the
whole provision with no text naming what went wrong: a registry 404, a bad engine
constraint and a network timeout all produced exactly the same nothing. The
operator is left at a dead end at the moment they most need the output, and the
obvious next move — run it again — reproduces the silence.

Output is captured rather than streamed, so a healthy run stays as quiet as it was
and the log is printed only when there is a failure to explain. The provider is
named, the exit status is preserved, and the command is echoed.

VERIFIED BOTH DIRECTIONS, with a negative control proving the old shape genuinely
produced a bare non-zero and zero text — otherwise the new test cannot tell that
anything changed.

TWO PROBE DEFECTS FOUND ON THE WAY, both of which would have had me "fix" working
code:
- My fake npm ended in `exit 7`. `exit` inside `eval` terminates the
  command-substitution subshell outright, so the function never reached its own
  error printing and the probe reported empty output. Real npm is an external
  command that RETURNS a status. The fake is now an external script.
- The dry-run assertion grepped for the command string, which the announcement
  line legitimately contains — a keyword check cannot tell "would: echo X" from
  having run it. Anchored to the line start instead.

This script targets Linux and the host here is Darwin, so the function was driven
directly rather than the provision being run end to end. Stated because it bounds
what the verification covers.
2026-08-05 05:53:06 -07:00
mvschwarz be335c0451 Describe the finding, not the finder
Code comments carried our seat names and governance vocabulary — a stranger
reading this SDK met an internal review roster instead of the reason for a
check. Reworded to state what was measured and why it matters; the reasoning is
unchanged, only the attribution is gone.

The sweep that guards this range could not have caught it: the pattern set held
canonical session strings and paths, not bare seat names or review vocabulary.
Extended, and the extension immediately found more than the first pass did.
2026-08-04 01:48:43 -07:00
mvschwarz 1c87452135 Ask whether systemd can be driven, not whether systemctl is installed
HAVE_SYSTEMD tested for the binary. On a host where systemctl exists but nothing
is running it — a container with the package, or `systemctl --user` as root over
SSH with no session bus — every --user call fails on the bus, and the first one
on that branch was `daemon-reload` through a bare eval under set -euo pipefail.
So the run died after a fully successful install: the same failure the branch had
just been rewritten to remove, one line above the part that got fixed.

It now probes `systemctl --user show-environment`, a read-only query that needs
the bus, and reports which of the two conditions it found — no init system at all
is a container and there is nothing to fix, while present-but-unreachable is
usually a missing session bus or linger and IS fixable. Saying only "no init
system" on the second sends someone to install what is already installed.

Proven by execution in a container carrying the systemd package unstarted, which
is the host shape a stock image cannot produce: the run reports the reason,
degrades to a direct start, and finishes ok:true — and a second run finds its own
studio on the port, reuses it by identity, and finishes ok:true again. Both of
the harness's negative controls still fail as they should.

This is slice 04's amended item 6, and it retires review-r1's Nit 4 by execution.
131 tests.
2026-08-03 23:53:21 -07:00
mvschwarz d1cf09f7fa Make the port probe answer the question the shell was only pretending to ask
sdk-qa returned three MUST-FIX findings against 300b970. All three were real and
two were outside the shape of my own harness, which is the part worth recording.

THE SHELL COULD NOT EXPRESS THE THREE STATES IT CLAIMED. `curl -f` reports a
refused connection and an HTTP 404 identically, so a foreign server answering 404
at /api/contract was classified as an EMPTY PORT — launched into, then verified
against, green. That is the original defect intact, one status code away from the
squatter. My four-server pass could not have caught it: every server in it
answered 200, so the case was outside the harness rather than missed by it.

The classification moves into the probe, which does its own TCP check first, and
only a missing listener may be "nothing". The shell now asks and reads an exit
code instead of inferring a state from a conflated failure. A probe that fails
for any other reason is treated as occupied, because refusing is recoverable and
launching into a stranger is not.

ORDINARY PATH SPELLINGS WERE REFUSED AS FOREIGN. Identity was a raw string
compare against a runtime that reports path.resolve'd output, so a trailing
slash, a relative STUDIO_DIR and a symlinked home each turned an idempotent rerun
into exit 5 after a successful install. I had disclosed the symlink risk and
missed that it was the general case: every path in my own pass was already
canonical, so the compare looked correct against four servers. Both sides are now
canonicalised through realpath, with resolve as the fallback for a path that does
not exist yet, and a negative control that two genuinely different directories
still refuse.

A THREE-KEY OBJECT WAS ACCEPTED AS A STUDIO. The validator asked only for a
string contractVersion and a truthy manifest, so anything carrying the expected
dir was adopted. It now establishes what contract-meta tells every consumer to
check — same major, contract.meta advertised — plus the runtime identity, which
this caller may demand by name because it installed that runtime itself.

Also: the first version of the hung-listener test left a pending promise that
cancelled every test after it WHILE REPORTING `# fail 0` — a worse failure than
the one it tested for. Resetting the connection reaches the same branch without
holding the event loop open.

QA's exact 404 case now classifies foreign at the shell level, and an empty port
still classifies nothing, so neither the module nor the wiring is simply refusing
everything. 131 tests.

Item 6 (systemd host, occupied port) remains WRITTEN AND UNEXECUTED. Nothing here
changes that.
2026-08-03 23:27:00 -07:00
mvschwarz 300b970b9e Let a studio say which studio it is, and make the provisioner ask
The provisioner asked a port whether a studio was there and treated any answer as
its own. review-r1 measured that a server returning a body which is not a studio
contract satisfies that check, and that two genuine studios are indistinguishable
by contractVersion, runtime and capabilities. With a sibling already on the port
the run reused it, never started the studio it had just installed, and the
verifier then passed against the sibling's surfaces — wrong, and reported as
success.

manifest.consumer.dir already answered the question. It shipped, two tests
asserted it, and nothing in contract/ documented it, which by contract-meta's own
classification makes it runtime-internal and off-limits to exactly the caller that
needs it. So this promotes what already ships rather than adding a field.

WHY IT WAS UNDOCUMENTED IS THE MORE USEFUL FINDING. The /api/contract drift guard
compares Object.keys(documented.manifest) — top level only — against an example
whose consumer is null. Every nested block was unguarded, so a field could ship,
be tested, and stay unpromised without anything failing. The guard now descends
into that block against an overlay-configured runtime. Written first, confirmed
failing for the right reason, then made to pass; planting an extra field in the
served block makes it fail again.

The reuse decision moves into provision/studio-identity.mjs with three outcomes
rather than two, because "nothing is listening" and "someone else's studio is
listening" call for opposite actions. A foreign port is now fatal on every branch
and fatal BEFORE the verifier runs, since verifying against a stranger is the
failure this fixes.

On a systemd host an occupied port used to abort through run()'s eval AFTER the
install had fully succeeded — apps installed, studio.json rewritten, service down,
nothing reported. It now reports and continues to verify, and says the part that
is easy to leave out: the running studio was NOT restarted, so it keeps the rail
it composed at its own boot.

Tests cover the decision itself, including a ZERO-CONFIG studio — a real studio,
answering every field, that still cannot be this one — and a positive control that
a studio serving this run's directory is still reused. Refusing every hostile
input proves nothing on its own: an over-restrictive guard and a correct one
produce an identical row of refusals.

The shell wiring was exercised against four real servers on this host — a matching
studio, a sibling studio, r1's squatter, and an empty port — because a tested
module reached through untested wiring is where this class hides. The provisioner
end to end remains unrun here: it refuses non-Linux hosts by design.

Slice 04-studio-identity, plan-locked. 122 tests.
2026-08-03 23:16:29 -07:00
mvschwarz 22a1ae6ed6 Bind the root kinds an app declared, not the ones the shell made up
ROOT_KINDS was built from Object.keys(app.json .roots) and then consumed by an
unquoted `for k in $ROOT_KINDS`, so names that came out of somebody else's
manifest were word-split AND pathname-globbed by the shell. Reproduced under
bash, which is this script's shebang:

  declared "footage archive"  ->  bound "footage" and "archive"; never the
                                  kind that was actually declared
  declared "zzz*"             ->  bound "zzz1" and "zzz2", filenames from
                                  whatever directory the provisioner ran in

Both write valid JSON, so the validator added right below them cannot see
either one. The studio is mis-rooted at runtime and the install reports
success — and in the glob case the wiring depends on where the operator was
standing. Found by review-r1, who measured it rather than reading past it;
I had looked at the same line earlier and called it cosmetic, having noticed
the word-splitting and stopped before the globbing.

The fix is not to quote harder. Kinds are now read, bound and serialized in
provision/root-bindings.mjs, so structured data never round-trips through the
shell's word list — the rule create-studio already follows for its manifest
row. It is a module rather than an inline `node -e` because the defect could
not be tested from inside a shell string, and a fix of this class ships with a
control that would have caught it.

test/root-bindings.test.mjs plants both defeating inputs, including real files
in the working directory for the glob case: without them an empty directory
makes both implementations agree, which is why this survived until someone ran
it somewhere real.

Two things found while fixing it, both the same shape as the original — a
declared kind vanishing while the output stays valid JSON:

  * a kind named "constructor" resolved through Object.prototype and bound to a
    function that JSON.stringify then dropped; a kind named "__proto__"
    assigned nothing at all. Bindings are built on a null-prototype object and
    membership is tested with Object.hasOwn.
  * the first version of the test's own fixture used a plain object, so
    roots["__proto__"] = … set the prototype and the fixture never carried that
    kind. The control for the prototype hazard was defeated by the prototype
    hazard, and it failed pointing at the module rather than at itself.

Also corrects the DRY_RUN comment's claim that a failing `&&`-tail terminates
the script under `set -e`. review-r1 measured that it does not, mid-script; it
bites only in final-command position. The overstated version was being read as
a general prohibition on a construct this file uses correctly elsewhere.
2026-08-03 22:43:28 -07:00
mvschwarz eab4232c88 Say what the bootstrap has actually run on
The STATUS header claimed the host bootstrap had never run on a machine without
node, npm or git, and that nothing here had provisioned a VPS. The first half
stopped being true the moment that branch ran against a stock ubuntu:24.04
container — the run that surfaced every defect fixed since. The header was
written when it was accurate and nothing told it otherwise, which is how a
status line in a durable artifact fails: it goes stale exactly when the work it
describes succeeds, and it is read by people who were not there.

So it now says what ran, and keeps the distinction it existed to hold. A
container is not a VPS. Real hosting is still unprovisioned, and persistence
across a reboot is still untested — the container had no init system at all, so
the systemd path went unexercised and the direct-start degrade is what ran.
Both halves stay named rather than quietly dropped, because a header that
claimed the bootstrap was simply "proven" would overclaim in the other
direction and lose the only thing it was for.

Comment-only: zero non-comment lines change, bash -n clean.
2026-08-03 21:26:35 -07:00
mvschwarz fe30f50c7e Reuse a running studio instead of launching a second one into its port
On a host with no init system the provisioner launched a studio on every run.
A second run launched another, which died on EADDRINUSE while the survivor
reloaded the manifest the run had just replaced — and the verifier waited only
for "/", which answers throughout a reload, so it asserted mid-reload and
reported FAIL on surfaces that were fine. A healthy box reproducibly returned
ok:false.

So: ask /api/contract whether a studio is already serving and reuse it if so —
the contract endpoint rather than the port, because the question is whether a
STUDIO is there, not whether anything is listening. And wait for the surfaces
the verifier is about to assert rather than for the front page, so it cannot
race a reload it caused itself.

Only the no-systemd branch needs this; systemctl enable --now is already
idempotent. The readiness timeout goes 30s to 60s because it now waits for
every surface rather than one endpoint.

Written and evidenced by cloud-impl against cloud-qa's retained container:
ok:false with two surfaces FAILing before, ok:true twice after, same box, same
apps. Routed as a patch for adoption rather than committed here directly.
Adopted after reading it, applying it clean at aada949, bash -n, and probing
the one shape that looked risky under set -euo pipefail: `[ "$ready" = 1 ] &&
break` as a loop body's last command survives, while a bare failing command in
the same position exits 1 — so the guard cannot silently end the script.

KNOWN AND NOT FIXED HERE: composeRail runs once, at studio.mjs boot, so a
reused studio serves the rail it composed at ITS start. A rerun that picks up
a CHANGED app list therefore verifies against a stale rail and fails honestly
rather than falsely — which is still an improvement on the previous behaviour,
where the relaunch died anyway and the run went red for a different reason.
Closing it means comparing the running rail against the app list before
reusing, and that is the patch author's call, not a change to make inside
someone else's fix.

No test ships with this: the provisioner has none, and the real check needs a
bare container. cloud-qa's container run is the evidence, and it is a better
check than anything that could be committed here.
2026-08-03 21:07:25 -07:00
mvschwarz aada949fb0 Ask the apps repository what it ships instead of asserting it
The provisioner carried a hardcoded app list and a hardcoded set of root kinds,
both true on the day they were written. The apps repository moves on its own
schedule: agents is gone, effects and provider-manager arrived, and the new
apps declare a root kind nothing bound. A fresh box then failed twice — once on
a missing app directory, once on an unbound root — having done nothing wrong.

Both are now read from the manifests at install time. An app named explicitly
that does not exist is still an error, because naming it was an instruction; an
app the repository simply no longer carries is not this script's business to
know about in advance. A root kind nobody has seen before gets a directory
under the media root, reported as a generated default so it is not mistaken for
a considered choice about what that root means.

A host with no init system no longer aborts a successful install. The studio is
started directly and the absence of persistence is stated plainly, because
throwing away a working studio over the way it gets restarted helps nobody.
2026-08-03 20:45:58 -07:00
mvschwarz 6123cb789f Install node as root instead of trying to run a flag as a command
The NodeSource step piped into "$SUDO -E bash -". As root SUDO is empty, so
that expanded to "| -E bash -" and the shell tried to execute -E, which meant
node was never installed on any host where the script ran as root — the normal
case on a fresh VPS.

It survived because every box it had run on already had node, so the bootstrap
branch was skipped entirely. A container built from a stock image, which has
nothing, took the branch on the first attempt and failed at line 50.

The dry-run had been printing the broken line all along. Reading "|  -E bash -"
does not look wrong; running it does.
2026-08-03 19:13:25 -07:00
mvschwarz e9870bb1ee Write shipped comments for a reader who was not here
Comments and docs across the tree narrated their own development: who reported a
defect, how many times a file had been wrong, which incident produced a rule.
None of that helps someone reading the code to use it, and some of it named
people and internal systems that have no business in shipped source.

Rewritten to state the rule and the failure mode forward. "Hoisting whichever
binding trips only moves the tripwire, so the position is asserted rather than
maintained by care" says everything the reader needs; the history behind it does
not travel.

Also in this pass:

- The provisioning script described the automation and hosting model it is
  called from. It now describes the constraints it is written to and nothing
  about what calls it.
- The clipboard shim carried an author, an internal project name, a path into a
  private tree and a foreign commit SHA in its provenance header. It now
  explains what OSC-52 is and why the shim exists.
- The shell hardcoded a rig name to order the launcher. A box now declares
  `primaryRig`; absent, order is left alone. A shipped shell should not know any
  particular rig exists — that was an identity leak and a design fault in the
  same line.
- Working-memory planning documents are removed from the repository.
- A test assertion string had been damaged by an earlier edit and no longer read
  as English. Repaired.

107/107.
2026-08-03 03:01:22 -07:00
mvschwarz 44b2e6518a Drop a warning's pointer to a document this repo does not carry
The linger warning referred the reader to a gap list that lives outside this
repo, so the pointer resolved to nothing for anyone who has only the script.
The warning states the consequence on its own.
2026-08-02 18:28:02 -07:00
mvschwarz 01d4f0fed7 Keep an internal path and a person's name out of shipped tooling
Two references in the header meant something only to the team that wrote it: a
pointer into a private mission tree, and a co-founder named as the owner of the
box this was proven on. Neither tells a reader anything useful about running
the script, and the second names an individual in a repo anyone can read.

Both replaced with the fact they were standing in for: the assessment lives
elsewhere, and the box it was proven on already had node, npm and git.
2026-08-02 18:27:42 -07:00
mvschwarz ccb504e691 Stand a studio up on a host that has nothing
The install itself was already proven by hand on a real box; what was missing
was something a machine could run unattended on a box with nothing on it. This
wraps that runbook: clone both public repos, write studio.json, install the six
apps, and leave a systemd user service behind.

It installs from the two public repos with no credentials on the target. That
is deliberate architecture rather than a rule to remember — there is nothing to
leak, so nothing has to remember not to leak it. Between-customer hygiene falls
out of the design; the box's own owner was never the concern.

Verification asserts effects, not exit codes: every declared surface must
answer, a verb must return real data rather than merely 200, and a bogus
surface must 404 — without that control the surface check would pass on a box
serving nothing.

npm ci exits 1 without a lockfile rather than skipping, which aborted the run
on studio-host until apps c0df1be added one. The per-provider branch stays so
the next provider added without a lockfile degrades loudly instead of killing
the install.

The platform guard runs before /etc/os-release is read: sourcing a missing file
terminates the script under set -e, so the check meant to refuse a non-Linux
host was unreachable there and failed with a bare exit 1 instead of saying why.

Host bootstrap is the one part never run on a machine without node, npm and
git. The assessment of that and every other gap lives in the mission tree, not
here — this ships, that does not.
2026-08-02 18:01:37 -07:00