Commit Graph

72 Commits

Author SHA1 Message Date
umbra audio
0b898316ef The password toggle was inverted on every password field
The variable was called 'reveal' and held `input.type === 'password'` - which is true
when the password is HIDDEN - and was then used to pick the label. So a masked field
offered 'Hide', and clicking it revealed the password and relabelled itself 'Show'. The
click handler inverted the same way, so the two errors cancelled and the visible result
was a control that appeared to work while doing the opposite of what it said.

Renamed to 'visible', which is what the value now means. A correct-looking name is not
what prevents this; the value and the name agreeing is.

Affects the login field and both fields on the change-password screen.

Verified by running the two states rather than by reading the edit: masked says 'Show',
plain says 'Hide', and two clicks return to the start.
2026-10-03 19:56:03 -07:00
umbra audio
0a4c400022 The console links to the nodes page, and a lost session leaves it
Three navigation faults, all found from the service request log rather than by looking
at the page:

- The console had no link to the nodes page at all. It was a standalone file I created
  and never wired in, so leaving the nodes page meant there was no way back except the
  browser. It is in the nav now, and every href on the console was checked against the
  running service rather than assumed.
- VIEWS omitted 'projects' although the section and its nav link both exist, so clicking
  Projects silently fell through to the dashboard. A link that looks live and does
  nothing is worse than no link. Flagged in an earlier session as not-my-problem and left
  alone; it was two characters.
- A 401 left the nodes page sitting there saying the session had ended, with nothing on
  the page that could work without one. It now redirects to the console, which is what
  the console does itself.

The empty organization dropdown was this too: with no session the organizations request
is 401, the list is empty, and the form is disabled with the new banner - which is
correct behaviour for a genuine no-organizations system and a misleading one for an
expired session. Redirecting first is what tells those two apart at the point where it
matters.

547 passed, 29 skipped.
2026-10-03 16:16:04 -07:00
umbra audio
14eba11ffe An empty organization list is now said out loud, and the page stops pretending
The organization dropdown rendered empty. Three separate defects behind it, found from
the service's own request log rather than by looking at the page:

- /api/uadmin/organizations returned 200 - the data was arriving - while
  /api/compute/nodes returned 422 on every load. ORGANIZATION_ID was only set when the
  account could see exactly one organization, so for anyone who can see several the node
  list was requested with no organization and the compute routes refused, as they are
  designed to. The list is now fetched when a choice is made, not at load.
- The page had no case for *no* organizations. A node belongs to exactly one, so there is
  nowhere to put it: the form is now disabled and a banner explains why, with a link to
  the organizations screen. A dropdown with nothing in it and no explanation is the same
  as a broken page.
- A 401 - an expired session - was indistinguishable from an empty list. It now says the
  session ended, which is the one failure the operator can act on.

Also removed eight CSS classes I had invented rather than looked up: card, card-body,
card-note, field, form-actions, table-wrap, alert--warn and button--quiet. None exist in
styles.css, so the page was rendering unstyled. Replaced with what the working console
page uses - admin-card, form-field, modal-actions, admin-table-wrap, admin-banner--
warning, button--ghost. Every class on the page is now verified present in the stylesheet
rather than assumed, and the markup is checked as balanced by a parser rather than by
counting tags by eye.

0005 was applied to the deployment to unblock startup: the tenant-schema contract check
runs at boot and correctly refused to start a service whose schema it could not find. That
is the check working. Backup taken and checksum-verified first; three tenant schemas
created, one per organization, with users=2 orgs=3 nodes=9 audit=46 unchanged.
547 passed, 29 skipped.
2026-10-03 15:28:30 -07:00
umbra audio
25cd17c061 The as_root checkbox help says what it now does
It described the control as a hint whose effect was decided at run time, which was
true and misleading: the paths are chosen here, at render time, and the command
follows.
2026-10-03 15:03:23 -07:00
umbra audio
0e29d863fb The as_root checkbox now changes the command it produces
as_root selects which paths the rendered script writes to: /opt/umbra and
/etc/umbra/node.json, or the invoking user's own directories. The command did not follow
it. Ticking the box produced a script that wanted to write to /etc while being run by an
unprivileged shell, which fails at the first mkdir - after the pairing code has already
been spent, so it also leaves the operator with a dead code and nothing to show for it.

The command now carries sudo when the box is ticked. The script still checks for root at
run time and takes whichever branch is actually available, so this is belt and braces
rather than the mechanism; but a control that produces a command which cannot work is
worse than no control at all.

The checkbox's help text described it as a hint whose effect was decided at run time,
which was true and misleading: the effect is decided here, at render time.
2026-10-03 15:03:04 -07:00
umbra audio
359e566a1f Record the TTS engine decision and what it still depends on
Qwen3-TTS as modified on the audio node, kept. Quality over time is the principle that
settles it, and the engine already in use is the one worth keeping.

The modifications are on the audio node and unknown here, so the speak work function
cannot be written blind. Listed what is needed: what changed and why, whether it is a fork
or a carryable patch, the input contract, and whether it loads a model and how large -
which decides whether node 238 can run it at all.

Parallel synthesis is a requirement, not an optimisation. The mechanism exists already
(submit_children and the decompose route), which means the work-function protocol has to
be designed for decomposition rather than have parallelism retrofitted into it.
2026-10-03 14:41:08 -07:00
umbra audio
4ce121470e A node may do work for any tenant, and the pipeline plan
`claim_job` took an `organization_id` and matched it against the job's. Read plainly
that says: a machine may only do work for the tenant that owns it. Both paired nodes
belong to BDMurphy and The Last Dwarf belongs to WorstAuthor, so a node could never do a
word of the audio node's work however idle it was. The only way to have found that out
was to queue the job and watch it sit there.

`place()` had the same fault - it listed candidate nodes by organization - so the
scheduler could not use the fleet across tenants either. Both are fixed.

A node is a machine. It belongs to whoever administers it, not to a tenant, and a
tenant's data reaches it inside one job's payload for the duration of that job, never by
widening what the node itself can see. `kind` and the placement requirements decide what
a node may do; ownership decides who may switch it off. What did not change: a node still
returns one job's payload and nothing else, so it still cannot enumerate another tenant's
work.

test_node_tenancy.py asserts the behaviour, and was confirmed to detect the old one: five
of its seven tests fail when the tenant filter is restored.

Also adds docs/plan-translation-pipeline.md, which separates the critical path - the
work-function protocol, TTS, assembly, lineage, and one finished episode of The Last
Dwarf - from the work that can proceed beside it.

Writing those tests cost more time than it should have and the reason is worth recording:
four of them failed for reasons that had nothing to do with tenancy. A job left behind by
one test is claimed by the next, which reads as the rule breaking. `TRUNCATE ... CASCADE`
on a table shared with other fixtures destroyed `project_publishers` and surfaced two
files away as "relation does not exist". The queue is now left alone and the claims filter
on a job kind only this file creates, so a neighbour's leftovers cannot be returned and
the assertions can be exact.

547 passed, 29 skipped.
2026-10-03 14:16:43 -07:00
umbra audio
715b7bc69f Restore the guarantee migration 0005 had to drop
`projects` moved into a per-tenant schema and `jobs` stayed in `public`. A foreign key
cannot cross that boundary without postgres_fdw, so 0005 dropped
`jobs_project_id_fkey`, `artifacts_project_id_fkey` and a composite `user_roles` key. The
migration recorded the consequence honestly - "the application still refuses it; the
server no longer does" - but the application did not.

So after the split nothing stopped a `public.jobs` row naming a project in a different
tenant, which is precisely the leak the schema split exists to prevent. `submit_job` now
verifies it.

Two things that were wrong in the first attempt, both caught by running the tests:

- The lookup was unqualified, relying on `search_path`. `ComputeRepository._tx` does not
  set it - only the account repository does - so the check ran against `public` and could
  never see any project, refusing every project-scoped job. The schema is now derived from
  the organization id and named explicitly: the check is self-contained, and does not
  depend on ambient state deciding whether a security check runs at all.
- It filtered on `projects.organization_id`. That column no longer exists - the schema the
  row sits in *is* the tenant, which is the whole point of the split. There is no
  predicate to write.

`test_a_nonexistent_project_is_refused` asserts that a caller cannot distinguish "no such
project" from "another tenant's project". From inside a tenant schema the two are the same
observation, and they should be.

Confirmed the tests detect the gap: with the check removed, three of five fail, including
`test_the_refused_job_is_not_created` - which exists because a refusal that still writes a
row is not a refusal.

547 passed, 29 skipped. Deployment untouched: 30 tables, 0 tenant schemas.
2026-10-03 14:16:35 -07:00
umbra audio
24d7ea6a41 Format the lines this change wrote, and leave the rest of the drift alone
ruff format disagrees with about two dozen files in this repository, most of them
untouched by this work. Reformatting them would bury the tenant-schema change in
noise, and reformatting only some of a file is its own kind of noise - so only the
lines written here are brought into line, and the pre-existing drift stays where
it is for whoever owns it.
2026-10-03 14:02:22 -07:00
umbra audio
0179355fe7 Drop the tenant schemas one transaction at a time
The cleanup I added dropped every tenant schema in a single transaction, and a
hundred runs' worth of them exhausts the lock table: the session teardown failed
with 'out of shared memory ... max_locks_per_transaction', which reads like a
PostgreSQL defect and is not one. It is the same constraint the 0005 rollback
documents and I wrote there before hitting it here.

Committing after each drop releases the locks, and the teardown now leaves zero
schemas behind, so a run does not make the next one slower.
2026-10-03 13:59:08 -07:00
umbra audio
9a84a9d227 The staleness fixture's project goes into the tenant schema, and comes out of it
Same shape as the append-only guards: the project row is created in the
organization's own schema and the cleanup drops that schema. The old cleanup
deleted from public.projects, which since 0005 has cleaned nothing up while
looking like it did - the sort of teardown that reads as careful and is not.
2026-10-03 13:42:02 -07:00
umbra audio
7b12964a9b Seed the guard fixture's project into the tenant schema it now lives in
public.projects does not exist since 0005, and this fixture was the last place
still writing to it. The project row moves into the organization's own schema
under an unqualified name, which is also the shape production writes it in - so
the artifact and job rows guarded by these tests are being set up the way the
application would set them up rather than the way the old shared table allowed.
2026-10-03 13:40:48 -07:00
umbra audio
152735f145 Assert the tenant boundary where it now lives, not where it used to
Three changes, all of them consequences of the migration rather than new
questions.

test_rls_coverage.py drops project_authors and project_publishers from the
policy list - they are per-tenant now and there is nothing for a policy to
filter - and replaces that entry with checks that are stronger than what it
replaces. A policy on a shared table proved the table was filtered; a per-tenant
table cannot be filtered at all, so the properties worth asserting are that no
shared copy exists in public, that no policy lingers on a tenant table, that
every tenant schema carries all four tables, and that organization_id is gone
from all of them. That last one matters because it was the column every removed
policy filtered on: leaving it behind would leave an application filtering by a
value nobody maintains.

It also gains a check that POLICY_TABLES names tables that are actually in
public. Without it, moving a table out of the policy list and out of public in
the same change makes both existing checks pass by not looking at it - which is
how a table can quietly stop being policed and still look policed.

test_tenancy.py covers the search_path function directly, and the case that
matters is the negative one: a request with no organization gets public alone.
A wildcard, or leaving the connection's default in place, would put every
tenant's tables in reach of a request that has not decided which tenant it is
acting for.

test_postgres_repository.py proves the new startup contract check holds against
the real app function, and that it can fail. A check that cannot fail is the
same shape as a policy nobody wrote: it reports success while enforcing
nothing.

The emptiness guard now also looks inside the tenant schemas and the run drops
them at the end. Not tidiness: a database holding nothing in public can still
hold somebody's books, and a guard that only looked at public would pass on a
database it was about to empty.
2026-10-03 13:37:39 -07:00
umbra audio
4781468181 Test the property that replaced the one a policy used to prove
The old file proved a policy refusing a row. With no shared table there is no
policy and nothing to refuse, so the tested property is gone rather than
rewritten - and Umbratools_2.md 5.0 says outright that this trade should be
noticed rather than discovered later. This commit is the noticing.

What is proved instead is stronger, not differently shaped. A statement with no
organization in hand asks for `projects` unqualified and is told the relation
does not exist: not zero rows, an error. That is a different claim from "the
policy filtered it", because a policy that was silently not enforced returned
zero rows and looked fine - which is the failure G2 was, and the reason the old
test had to run as a non-owner role to mean anything.

The new tests keep the non-owner discipline for the part that needs it. Naming
another tenant's schema is refused outright; repointing the search path at it
and using an ordinary name finds no table at all, because a role without USAGE
on a schema does not see its contents when names are resolved. That second one is
the outcome an application actually experiences, so both are asserted rather than
the convenient one.

Positive cases are here because a suite that only proves refusal passes just as
happily against a database where every write is refused: a write that must be
allowed is performed and then counted in both tenants.

What the file cannot prove is written down in its docstring rather than left to
be assumed. The application owns every tenant schema, so it can reach another
tenant's tables by naming that schema, and schema-per-tenant cannot change that
with one role serving every tenant. What it changes is that there is no shared
relation to reach through, and reaching another tenant means writing down another
organization's name - one call site in tenancy.py rather than a predicate in
every query.
2026-10-03 13:33:34 -07:00
umbra audio
79b3484083 Reach the tenant tables by name, not by a filter somebody remembers
Every project statement now says `projects` unqualified and takes its
organization through search_path. A shared table filtered by
`organization_id = %s` is a filter somebody has to remember, and there is always
a write path that would not have it; an unqualified name resolves into the
caller's own schema or it does not resolve at all. The strongest possible
mistake - forgetting to scope a query - now raises instead of returning a wider
result set.

Project.organization_id survives in the model and is filled in from the schema
the answer came from. Callers still see which tenant a row belongs to; it is
derived from where the row is rather than from what the row says about itself.

Two consequences that are worth more than the mechanical change:

  - assign_user_to_project now takes the organization as an argument. It used to
    read it off the project row and copy it into user_roles, and there is no
    column to read now. user_roles is platform-level, so it cannot be written to
    by looking inside a tenant schema - so the caller, which has already resolved
    the project, has to say it. The protocol declares it, and the in-memory store
    accepts and ignores it rather than being the one implementation with a
    different signature.

  - A platform administrator listing projects without naming an organization now
    gets the answer assembled across tenant schemas, because there is no shared
    table to ask. That is one round trip per organization and it is paid only by
    that one caller; every per-tenant path, which is what an ordinary request
    does, is still a single statement. The merge reproduces the ordering and the
    total a shared table would have given, so paging across the boundary is the
    same as paging inside one.
2026-10-03 13:32:46 -07:00
umbra audio
2264a6cc42 Publish the caller's schema onto every transaction
Two things now go onto a pooled connection before a statement runs: the caller's
identity, which the surviving platform policies read, and the caller's schema,
which decides which tenant tables the statement can even name.

search_path is set on every transaction rather than only the ones that have an
organization, so a platform context gets public alone instead of whatever the
connection's own default happens to be. That is the difference between a design
and a hope: a default naming a tenant schema is a silent leak with no line of
code mentioning it.

verify_tenant_schema_contract exists for the same reason verify_rls_contract
does. The schema name is derived in two languages - this one and app's - and
nothing joins them. A disagreement is silent and total: every tenant query
resolves into a schema that is not there, every project request for every
organization raises "relation does not exist", and the error names the statement
rather than the cause. That presents as an empty database, which is the one
diagnosis nobody reaches for.
2026-10-03 13:31:41 -07:00
umbra audio
e1bbfdb604 The session-to-schema step, next to the session-to-organization step
tenant_search_path is the whole isolation model in one function: given the
organization a request resolved to, return the schema its tables are in; given
none, return public alone. That second case is the point. Returning a wildcard
- or leaving the connection's own default in place, which may name a schema
nobody chose - would put every tenant's tables in reach of a request that has not
decided which tenant it is acting for.

The name is derived from the organization id rather than stored, for the reason
the migration derives it too: a recorded name that disagreed with the schema the
rows are in would describe data that no computed name can reach, and that state
would be indistinguishable from "this organization has no data". Deriving makes
it unrepresentable.

This lives in tenancy.py rather than in the adapter because it is the same
decision as scope_for, not a second decision next to it. A module that picked
the organization and another that picked the schema would be two places for the
boundary to be got wrong, and the schema one is the silent one: an unqualified
projects query simply finds nothing, and reads as an empty database.

The value is quoted rather than interpolated because search_path parses its value
as a *list*. A name carrying a comma would not name a schema, it would append
one. The check before that is unreachable from a UUID and is there anyway.
2026-10-03 13:30:55 -07:00
umbra audio
2cdebf5ac3 Schema per organization: the migration, and why these four tables
Umbratools_2.md 5.0 replaces shared-tables-plus-RLS with one schema per
organization, because RLS was measured to be unenforceable twice: the
application connects as the table owner so the policies did not apply to it
(gate G2), and one app function was PUBLIC-executable until it was fixed by
hand against one database (defect B4). A model whose correctness depends on a
grant somebody remembers to make is not a boundary.

The classification comes before the SQL because the SQL's blast radius depends
on it, and the obvious reading of "move the tenant tables" is wrong for eight of
the tables carrying an organization_id. Four move: projects, project_authors,
project_publishers, invitations. The other twenty-six stay, each for a reason
read out of the actual columns and foreign keys rather than the table names.

Two findings the plan did not price, both recorded in the classification:

  - user_roles cannot move. It is the index that resolves a session to an
    organization, so putting it in a tenant schema means finding the tenant by
    searching every tenant schema, on the login path. It also holds
    platform-scope rows, which have no organization to belong to. So one table
    stays shared, and 5.0's "no table is shared between organizations" is not
    fully achievable while the session-to-tenant resolution is a table.

  - moving projects forces dropping three foreign keys that point at it from
    artifacts, jobs and user_roles, because a foreign key cannot name a table
    whose schema depends on the row. That is the one place the decision costs a
    database-enforced guarantee, and it is written down rather than papered over
    with a trigger that would have to rediscover which tenant is meant.

The migration names each tenant schema from the organization id rather than
storing it, so a name cannot disagree with the rows and the rollback can rebuild
the organization_id column from the schema. It is idempotent - verified by
applying it twice - and the rollback is a file that runs rather than a comment
block, because a rollback for a data migration that only exists as a comment is a
rollback nobody runs. Both were applied to the scratch database, rolled back, and
applied again.
2026-10-03 13:30:38 -07:00
umbra audio
62372a9c55 Wider code display, copy button beside the value
Feedback on the add-node page: the command was hard to read and hard to copy.

Both the command and the six-digit code now use .otp-value-row / .otp-value - the
wide monospace value with its copy button beside it that the provisioning dialog
already uses. Reusing that rather than writing new CSS, because a second styling of
the same idea is how the two drift apart.

The command was a <textarea> carrying a class named `code-block` that exists nowhere
in the stylesheet - I had invented it - so it rendered as a default textarea rather
than the monospace block it was meant to be. It is now a <code>, which means it is
written with setText rather than .value; setting .value on a <code> silently does
nothing, which is the same class of silent failure as the missing toast element.

The standalone "Copy the command" and "Copy the code" buttons at the bottom are gone:
each value now has its own button next to it, so there is one obvious way to copy each
thing and no need to match a button to a field.

Done is now labelled to say what it does - "clear this from the page" - because the
code is only in the page's memory and the controller cannot produce it again.
2026-10-03 12:03:39 -07:00
umbra audio
a75959c2e9 Fix the add-node page: nothing happened because three bugs, all silent
Reported as "I entered info, clicked create, and nothing happened". Three separate
faults, each of which fails quietly, so the page looked like it simply did not work.

**1. The toast element had the wrong id.** api.js's showToast() looks for
`id="toast"` and returns silently when it is absent. The page had
`id="toast-region"`. Every message - success and failure alike - was appended to
nothing. This is the direct cause of "nothing happened": the request may well have
succeeded and the answer was thrown away. The working console page uses id="toast";
mine should have matched it.

**2. Seven `el()` calls passed a className where api.js wants a props object.**
`el(tag, props, children)` assigns `props.class`. `el('td', 'table-note')` therefore
set an attribute literally named "table-note" and no class at all, so the node list
would have rendered unstyled. Caught by reading the helper instead of assuming its
signature from the name.

**3. A platform administrator could not use the page at all.** The compute routes
refuse to infer an organization - they raise `organization_required` and ask for
`?organization_id=...`. The first version read an `organization_id` out of
/api/uadmin/summary, which has no such field, so it was always empty and every create
was a 422.

The page now loads organizations from /api/uadmin/organizations and presents a picker,
sending the chosen id as a **query parameter**. It went in the JSON body at first,
where the route declares it as a Query - a body field of the same name is silently
ignored, which looks exactly like it worked. An organization administrator has exactly
one and sees it preselected rather than being asked to choose.

Also added `defer` to both script tags, as the working console page has, so api.js is
defined before nodes.js boots.

Verified the corrected request path end to end: the same payload the browser now sends
produces a code, and that code pairs a node on 238 through the `curl | sh` flow.

525 passed, 27 skipped.
2026-10-03 11:44:35 -07:00
umbra audio
c3a3e506ec Add a node from the console, and make curl | sh actually work
The page an administrator uses to bring a node online: name it, get a one-line command
and a six-digit code, hand both to whoever is at the machine. That is the last piece
between "someone runs a command" and self-service onboarding.

POST /api/compute/pairing-script now returns JSON - script, code, expiry and the
command - rather than the bare script. There are two callers with two needs: a person at
a terminal wants a script to pipe to sh, and the console wants all three values so it can
show them once and then forget them. Returning text would have forced the console to
parse a shell script to recover a six-digit number, which works until someone reformats
the script.

The code is shown once and there is no button to fetch it again, because the controller
keeps only an Argon2id hash. That is the server's limit, not a UI choice, and offering a
"show code" button would be a lie about what it can do.

## Three defects the template flow exposed

Running `curl ... | sh` on node 238 - the flow the console hands out - found that the
template could never pair, for three separate reasons:

- It had no prompt and no code. `000000` is the placeholder that means "nobody filled
  this in", so the script now asks. A rendered script from the console has the code
  substituted and skips the prompt, so nobody is asked for a number they do not need.
- It had no node name, and the name is what the code is bound to. It now asks too. The
  first version failed with "check the code has not expired" while the code was fine and
  the *name* was the placeholder - an error pointing at the wrong thing.
- It hardcoded `controller.invalid` as its controller address, so every deployment but
  one got a script that could not resolve its own controller. The address now comes from
  the Host header the template was fetched with, and `UMBRA_CONTROLLER` overrides it.

The error message itself was part of the problem: a DNS failure reported "check the code
has not expired", which is a worse fault than no message because it sends an operator to
check the one thing that was working.

## Verified

`curl -fsSL http://192.168.123.14:8442/api/node/bootstrap | sh` on 238, code and name
typed at the prompts: paired, agent installed, service registered, node `online`.

`/api/node/bootstrap` is unauthenticated and returns only the inert template - a script
that can check a machine and then stop. It is listed by name in the test that pins
unauthenticated node routes, alongside /pair, with the reason each is safe.

## Also found, not fixed

`VIEWS` in web/assets/admin.js is `['dashboard','accounts','organizations','audit']` but
`view-projects` exists in the markup, so the projects view can never be activated. It
predates this work and I have left it alone rather than change navigation behaviour as a
side effect of adding a page.

525 passed, 27 skipped.
2026-10-03 11:10:42 -07:00
umbra audio
bc031dd079 Bootstrap: one file, and it works with or without root
Enrolling a node meant a person reading instructions and running them, with a
credential typed into a terminal. That does not scale, and it makes the controller a
courier. The flow is now: an administrator renders a script, hands over one curl, and
the node sets itself up.

`GET /api/node/agent` serves the agent to a credentialed node. A node credential
already implies "run what the controller sends" - that is what claiming a job is - so
this adds no authority; it removes the hand-copying. `POST /api/compute/pairing-script`
renders the bootstrap, minting a pairing code server-side so a code pasted into a
request body cannot be one issued earlier for something else.

## The exclusive/shared distinction, encoded

The script asks whether it has root and branches. With root: `/opt/umbra`, a system
unit, and the agent running as a dedicated `umbra-node` account with NoNewPrivileges and
ProtectSystem=strict. Without: the user's own directories and a user unit. Both layouts
are in the output and the runtime decides, because whoever renders the script does not
always know how the person at the machine will run it.

The credential goes in a 0600 file referenced by systemd's EnvironmentFile. Three
alternatives were rejected and the reasons are in the code: a unit file is
world-readable, a config holding a secret ends up in a backup, and the agent
deliberately refuses to read the key from its config when the environment can carry it.

## Verified on node 238, not just reasoned about

Ran it as `umbra` with no root: paired, installed, registered, and the agent went
`online`. Six defects found only by running it:

- `$BOOTSTRAP_VERSION` was interpolated into the shell body but never declared as a
  shell variable. `set -u` caught it on the first line that used it.
- The agent fetch sent no credential and got a 401 - correct behaviour, wrong script.
- `ExecStart` pointed at the agent file directly. It has no shebang, so the kernel
  tried to run it as a binary: "Exec format error", a service in a restart loop, and
  nothing on the controller. Now invoked through `python3` explicitly, and the file is
  644 rather than 755 because it is source.
- `sed -i` with no backup suffix is GNU-only; BSD reads the next argument as a suffix,
  so on macOS it would leave the unit unfiltered and install hardening directives that
  cannot work. Replaced with a portable filter.
- Pairing a name already in use surfaced as a bare 500 from a UniqueViolation. Now
  `node_name_taken`, with an answer that says pairing does not replace an existing node.
- The agent logged "claim failed: node_scope_required" every second, forever. A
  read-only credential can never gain compute:claim, so this is permanent, and a
  warning that cannot change trains a reader to ignore warnings. Announced once.

## A privilege bug the pinned tests caught

The bootstrap renderer declared an administrator dependency while sitting on the *node*
router. A node could have called it to mint itself a credential for any node name. It
is now `POST /api/compute/pairing-script` on the admin router, above the agent-surface
marker so `test_every_node_route_uses_node_credential_authentication` keeps meaning
something.

Also worth noting: a node cannot be DELETEd from the database, because the pairing-code
reuse trigger refuses the `ON DELETE SET NULL` cascade. That is correct - the status
enum has `retired` and the admin route drains and retires, so removal is a status
change, not a row deletion. Found by trying to clean up after a failed test run.

525 passed, 27 skipped. Test nodes boot-test-1..3 are retired, not deleted.
2026-10-03 09:53:28 -07:00
umbra audio
c56ef46190 Measure capability by executing instructions, not by reading cpuinfo
/proc/cpuinfo is a declaration. On node 238 it reports 'AMD Ryzen 9 3900X' and then
lists 65 CPUID flags that stop at SSE4.2 - no avx, avx2 or fma, on silicon that has
all three. Executing an AVX2 instruction there faults with SIGILL, so the hypervisor is
presenting a CPU that does not exist.

That is the same defect the compute design already has, one layer down: the agent sets
throughput_factor by string-matching a GPU model (40.0 if '4070' in name else 20.0), a
declared factor wearing a measured name. Here the hypervisor makes the same kind of lie
about instructions. So a feature is recorded as one of three things and never two:

  present    - an instruction used it and produced the right answer
  absent     - an instruction used it and the kernel killed the process
  unverified - it could not be executed at all

Only 'present' counts as usable. An unverified feature is treated as unavailable on
purpose: the cost of pessimism is a slower path that works, and the cost of optimism is
a job dispatched to a machine that dies on it.

Measured on node 238: sse2, sse4_2, bmi2, aes_ni usable; avx, avx2, fma, avx512f all
SIGILL. On oldbox: everything but avx512f.

Four bugs found while building the probe, each of which would have made it report
something false:

- Inline asm with wrong operand counts, for all eight features. Replaced with
  intrinsics, verified by disassembly.
- One binary for every feature could not compile: -mavx2 alone makes the FMA, AVX-512
  and AES intrinsics fail always_inline. Every feature reported unverified on a machine
  that has most of them. Now one translation unit per feature.
- GCC folded the probe at -O2 with constant inputs and emitted nothing, so it would
  have reported 'present' for every CPU on earth. Inputs are volatile and the expected
  mnemonic is checked in the disassembly; test_a_folded_probe_is_reported_unverified
  builds a deliberately foldable probe and asserts it is caught.
- The SSE4.2 probe stored a comparison mask through _mm_storeu_pd and read a double back,
  getting NaN - reported as 'executed but returned a wrong result' on every machine.

Also: a caller passing an explicit compiler path that does not exist got eight build
errors instead of one clear sentence; the compiler is now verified to work, and a
missing or broken one is a False rather than an exception.

525 passed, 27 skipped.
2026-10-03 09:26:22 -07:00
umbra audio
11f27f5ef9 Capability profile for node 238, measured over SSH
Reached 238 via public-key enrolment - no password anywhere. The first attempt failed
because the key had never landed in authorized_keys; the second worked once the
fingerprint matched.

Findings worth keeping beyond this node:

The VM reports 'AMD Ryzen 9 3900X' and then lists 65 CPUID flags that stop at SSE4.2.
No avx, no avx2, no fma. That silicon has AVX2 and FMA, so VirtualBox is masking them
and /proc/cpuinfo is a declaration, not a measurement. Recorded as *unverified* rather
than absent, because confirming it needs an actual AVX instruction executed and there
is no gcc on the guest - a flag list cannot settle it.

This is the defect the compute design already has, one layer down. The agent sets
throughput_factor by string-matching a GPU model (40.0 if '4070' in name else 20.0),
which is a declared factor wearing a measured one's name. Here the hypervisor makes the
same kind of lie. Capability discovery therefore has to probe by executing
instructions and treat an unverifiable feature as unavailable.

Also: cpu.max is 'max 100000' so the guest is not CPU-capped, memory is 47.0 GiB
unballooned, no GPU and no nested virt flags, and umbra has no sudo. Recorded in docs/
node-238-capability-profile.md with how each value was obtained.
2026-10-03 08:55:40 -07:00
umbra audio
0c35eb2d03 Allow the address the service binds to as a Host value
The deployment listens on 192.168.123.14 and TrustedHostMiddleware rejected that same
address, so every request by IP returned a bare 400 Invalid host header - including
/api/health, which is indistinguishable from the service being down. It has cost this
project twice.

Found when the first pairing attempt from 192.168.123.238 came back 400 with no body
and no logged detail. The pairing code was fine and went unspent; the request never
reached the route.

Both umbratrans.bdmurphy.me and 192.168.123.14 are now allowed. The 0600 on the env
file is unchanged, and the correction is recorded in the alignment doc's transport
note so the audio node is not sent looking for a service outage.
2026-10-03 07:51:14 -07:00
umbra audio
32dc580c74 Pair nodes with a short code instead of a copied credential
Issuing a node credential by hand required writing the key into a 0600 file on the
controller and having a person copy it onto another machine. That key landed in shell
history and a terminal buffer, and it made the controller a courier: nothing could be
enrolled without me in the middle, which is the opposite of what a fleet of
heterogeneous nodes needs.

`POST /api/compute/pairing-codes` (admin session) mints a six-digit code for a *named*
node. `POST /api/node/pair` exchanges it, once, for a credential returned in the
response. The node never sees a secret typed by a person, and the controller is no
longer in the path.

## Why a six-digit code is defensible

1,000,000 possibilities is nothing on its own. What makes it work is everything around
it, and any one of these alone would be too thin:

- **Single use.** Redemption is `SELECT ... FOR UPDATE` then `UPDATE ... WHERE
  used_at IS NULL`, in one transaction.
- **Expiring.** Default ten minutes, hard-bounded to 60-1800s. Long enough to walk to a
  machine; short enough that a code left in a terminal buffer is not a credential for
  the day.
- **Bound to a node name.** A code read out of a terminal buffer cannot pair a machine
  called something else. Without this, "confirm it is the machine" is decoration.
- **Argon2id at rest.** A database read or a support transcript yields nothing
  replayable.
- **Rate limited per source address**, charged *before* any code lookup, so guessing
  across many codes costs the same as guessing at one. Twenty per quarter-hour against
  a million-code space is about 21 years. This is the control that makes the length
  defensible; the others are cheap additions to it.

Every rejection is worded identically - "no such code", "wrong code", "expired",
"already used" all read the same - so the response is not an oracle for the rate
limiter to work against. The one exception is a name mismatch, which says so plainly:
it is not secret, and an operator staring at a failure needs to be told the code is for
a different machine rather than left guessing. A name mismatch does *not* spend the
code, because the obvious implementation spends it and then the retry with the right
name cannot work.

## Why not a SECURITY DEFINER function

The obvious shape is a plpgsql function that verifies and redeems, so the node surface
never touches the tables. It cannot be done in SQL here: the codebase hashes with
Argon2id in Python, and the only hashing PostgreSQL offers in this database is
`crypt()` (bcrypt and MD5). Writing pairing against bcrypt would claim an algorithm
choice the rest of the system does not make. So verification is in the repository, which
means the pairing tables need grants for the application role when G2 lands, and the
path needs re-testing as a non-owner role. Recorded in the migration header.

## Three defects found by writing the tests

- **Redemption order.** Claiming the code before inserting the node it references
  violates the foreign key, so *every* redemption failed. Found by nine failing tests
  with an FK error that had nothing to do with pairing logic.
- **A race that produced a 500.** Two nodes verifying one code simultaneously both
  created a node, and the loser hit a unique-constraint violation on
  `(organization_id, name)` - an unhandled error for an entirely ordinary condition.
  Fixed with `SELECT ... FOR UPDATE` on the code row before the insert, so the second
  caller blocks, re-evaluates `used_at IS NULL`, and gets the ordinary refusal. The
  concurrency test fails when the lock is removed and passes when it is present.
- **A test that could not fail.** Removing the `used_at IS NULL` from the *claim* left
  all tests passing, because the candidate query's `used_at IS NULL` filter masks it.
  Sequential redemption cannot distinguish the two guards; only a concurrent one can.
  That is now `test_a_concurrent_redemption_produces_exactly_one_node`, and
  `test_a_spent_code_is_not_offered_as_a_candidate` pins the first guard directly.

The reuse trigger needed a correction too. It originally refused any change to
`expires_at`, which would have made it impossible to revoke an unused code - the
normal way to withdraw a code issued to the wrong person. It now refuses identity
changes and expiry *extension*, and permits expiry to move earlier only.

Two existing tests flagged the new unauthenticated route, correctly:
`test_the_node_surface_is_exactly_what_a_node_may_do` and
`test_every_node_route_uses_node_credential_authentication` both assumed every
`/api/node` route needs a credential. Rather than weaken either, `/pair` is recorded as
a named exception in both, with a check that the named route still exists - so a second
unauthenticated node route has to be added deliberately with a reason.

509 passed, 27 skipped. Deployment: 0004 applied after a backup, health 200, and the
live endpoint verified to refuse a bogus code with 403 and a wrong node name with a
`pairing_name_mismatch` that does not spend the code.

G2 is still open, and the pairing tables will need grants when it closes.
2026-10-03 07:25:41 -07:00
umbra audio
382ca8fc78 Derive node status on read; a heartbeat loop for the audio node
Two problems, both found by the audio node running Stage 1.

## A node that went quiet still looked available

`compute_nodes.status` is promoted to `online` by a heartbeat and demoted to
`offline` by an administrator. Nothing demoted it on the passage of time: grep found
`offline` appearing in exactly one place, the heartbeat's promotion CASE. So an agent
that was killed left the row reading `online` indefinitely - measured on the
deployment, where `umbraaudio-m4` showed `online` against a last heartbeat nine hours
old.

That is the failure invariant I24 exists to prevent. `create_node` refuses to mark a
node ready before its agent has ever spoken, and then nothing noticed when it stopped
speaking.

Derived on read instead, in `effective_status`, called from `_node_from_row` so every
caller - console, heartbeat response, placement - gets the same answer:

    offline: no recent heartbeat (derived; the stored status is still 'online')

Three decisions inside it:

- Only `online` is derived away. `draining` and `retired` are administrator
  decisions about intent, and a machine going quiet does not retract an order to stop
  taking work. Demoting a draining node would make the console report it as gone
  rather than winding down, and blame the wrong thing.
- Nothing is written back. The stored value stays as-is and is carried on the model as
  `stored_status`, so a GET has no side effect and two readers cannot disagree about
  what the column says. `NodeResponse` reports it only when it differs from `status`.
- The stale-window check that was hand-rolled in the placement loop is gone. Two copies
  of one rule is how they drift; the loop now reads the same function as everything
  else, which is what makes the console and the scheduler agree.

A sweeper was the alternative and is how expired leases are handled, so it was not a
strange idea. It loses because it is a periodic task that must actually run on a
deployment that has already been taken down three boots by a stray process - and while
it is not running, the stored value is wrong again. Computing on read has no window and
no background dependency.

## The document was wrong about what happens when a node stops

It said "miss that window and the node goes offline". That was false, which is how this
was found. The audio node followed the instruction, sent one heartbeat, and nothing
told it or anybody that a second one was expected.

`poc/umbra_stage1.py` is a loop rather than a description of one, so the audio node can
drop it in unchanged. One `curl` is one request: it returns to the prompt when done,
which is correct for curl and indistinguishable from a healthy node that never started.
The script runs in the foreground so closing the terminal stops it, which makes the
first few minutes obvious - press Ctrl-C and the node goes quiet, and about 45 seconds
later the controller says so. It exits on 401 and 403 rather than retrying once every
ten seconds for an hour, because a credential problem does not resolve itself, and it
sends an empty report rather than nothing when the report script is missing, since a
node that stops heartbeating because its report broke is indistinguishable from a dead
machine.

Verified against the live controller: three consecutive 200s at a 10s interval, and a
wrong key producing 401 and a clean exit.

Verified on the deployment: a node quiet for 50s reads `offline` while the column still
says `online`, and reads `online` again one second after a heartbeat.

488 passed, 27 skipped. The three staleness tests were confirmed to fail with the
derivation neutered, and the export test caught `effective_status` missing from
`__all__`.
2026-10-03 06:07:43 -07:00
umbra audio
2ff322dcd2 Close all thirteen app functions in the migration, not by hand
B4 was recorded as open against the deployment. It was not open there - the ten
original functions had been closed by hand - but it was open everywhere else. A
database restored from a backup of the deployment comes back with 10 of 13
PUBLIC-executable, because a hand-applied GRANT is a property of one database and
does not survive a restore. Observed on scratch, built from the pre-0003 dump.

So 0003 now revokes from PUBLIC on all thirteen, by full signature taken from
pg_proc.oid::regprocedure: a bare () does not resolve for a function taking one
argument, and REVOKE on a nonexistent name aborts the whole migration.

Three findings while doing it, none of which would have shown up without running the
tests:

- My own three guard functions went in PUBLIC-executable, reproducing the exact
  defect B4 is about.
- Revoking PUBLIC without regranting broke five tenancy tests with a permission
  denied error on the platform-admin helper. Every RLS policy calls it, so a missing
  grant here looks like a broken policy rather than a missing grant. Regranted to the
  roles that actually read and write, guarded by EXISTS so a role added later is
  covered by the next migration rather than by luck.
- The revoke block originally sat before the functions were created, so it failed on
  a fresh restore and passed on the already-migrated database - passing by accident,
  the failure mode this project keeps hitting. Both revoke blocks now sit after the
  triggers, and the fresh-restore path is the one verified.

Verified by restoring the pre-0003 backup into scratch, applying 0003, and running
the suite: 0 of 13 PUBLIC-executable, 4 guards, 474 passed, 27 skipped. Re-applying
is a no-op. Deployment data unchanged (users=2 orgs=3 audit=43 perms=20); health
200, heartbeat 401 without a credential and 200 with one, so the revoke broke
nothing.

G2 is still open: 26 tables have RLS enabled but not forced, and the application
connects as the owner.
2026-10-02 20:25:12 -07:00
umbra audio
c0a1971a52 Stage 1 credential issued; record what it was verified not to do
The doc previously said 'ask and we will issue'. It now carries the node id, the key
prefix, the four observed checks, and the heartbeat payload shape.

The key itself is not in the document. It is in a 0600 file on oldbox, because a
credential in a git-tracked design document is a credential in every clone and in
every paste of it.

The two rows worth reading are the third and fourth: a heartbeat-only credential
claiming a job is refused 403, and the job is still queued with no lease owner. The
refusal happens at the scope check, before the job is touched - a refusal that had
already half-claimed would leave the queue believing a node is working when it is not.
2026-10-02 20:02:51 -07:00
umbra audio
0501739a88 append-only guards, and a fixture that could not set itself up
Three defects, found by applying 0003 to the deployment after a restore-tested
backup. Each was caught by a check failing for a reason that had nothing to do with
what it claimed to check.

artifacts had no immutability guard, so content addressing was a convention. Its
identity is the bytes: an UPDATE that changes content_hash or storage_key rewrites
history for every job that consumed the row, and the lineage in job_artifacts would
point at content that never existed. job_artifacts and job_events had the same gap -
provenance and the event trail behind I25/I26, both editable in place.

The guard on artifacts is UPDATE only, and that is a decision rather than an
oversight. Guarding DELETE as well would make storage reclamation impossible: no
artifact row could ever be removed, and the table would grow for the life of the
deployment. The provenance guarantee does not rest on that guard.
job_artifacts references artifacts ON DELETE CASCADE, so deleting an artifact any job
consumed fires the guard on job_artifacts and is refused. An artifact nothing consumed
is by definition nothing anyone's record depends on. Four cases are tested in both
directions: each test was confirmed to fail when its trigger was disabled or widened,
so none of them passes because its mechanism failed.

test_rls_tenancy's fixture inserted a role with a fixed id and ON CONFLICT DO
NOTHING. The unique constraint is on (scope, name), so the insert silently did
nothing - organization_admin already existed under a different id - and the fixture
then assigned user_roles to a role that was not there. validate_user_role_scope
raised inside the fixture and all eight tests errored rather than failed, which reads
as "the thing under test is broken". It now looks the role up and says so if the
role is absent.

test_append_only_guards.py needs no teardown: job_artifacts guards DELETE, so its
rows cannot be removed, and fixed ids would collide on the next run. Ids are
generated per run instead. The database is disposable by construction, which is what
UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE=1 asserts.

474 passed, 27 skipped, 0 errors.
2026-10-02 20:02:33 -07:00
umbra
898906ab04 Forward-only link agreed, and the connection sequence the audio node asked for
D16: a book_local character revealed to recur keeps its released entity
and gains a recurs_as edge, rather than being merged. Its cost is written
down rather than discovered later - a retelling from the earlier book's
point of view treats the two as different people. Chosen because the
freeze is what protects reproducibility and a merge puts it at risk for a
gain that is real but retrospective.

Asked whether the document was ready to feed back and how the audio node
connects. Reading it against that question found four gaps, all of which
were mine:

- One report skeleton was given for nine phases, which specifies nothing.
  evidence now has a tabulated shape per phase, so the reports are
  mechanically comparable rather than approximately so.
- The identity probe said "we send you a fixed set" without saying how
  the set was fixed, which would have made phase 3 either
  unreproducible or impossible. Now: you pick the project, sort by
  source_unit_key, every 17th, cap 200, and report the stride.
- Section 9 said "contact the controller on 192.168.123.14:8442" and
  stopped, which is how this session's own 400s happened twice.
- There was no connection sequence at all.

Section 10 adds it, in four stages. Stages 0 and 1 are possible today
and carry no project data: Stage 0 is reachability, where a 401 from the
node surface is the correct answer and the evidence; Stage 1 is the node
report they already wrote, sent as a heartbeat. Stage 1 needs no schema
change and no new endpoint, because node_heartbeats.raw is an open jsonb.
Stages 2 and 3 are ours to build and are listed as such.

Two transport facts stated plainly, both learned the hard way here. The
deployment accepts exactly one Host header and returns 400 on everything
otherwise, including health. And there is no TLS, despite the service
description claiming "TLS terminated upstream by Caddy" - Caddy is
inactive and its configuration is empty. Tolerable for a read-only node
report on a LAN, not tolerable for the ingest path, so putting Caddy in
front of the port is a Stage 3 prerequisite rather than an extra.

It starts read-only for the same reason the compute substrate does: a
node is online because it heartbeated, not because it was created. A
mirror that begins with data cannot distinguish "the node is broken"
from "the data is bad", and their speaker counts already disagree.

One stale block survived a previous edit - a replace that matched
nothing and reported success. It now asserts before replacing.
2026-10-02 19:11:14 -07:00
umbra
717e1d56b9 Editions, character persistence, and a freeze anchored per character
Five more decisions, and the second one closes the last three questions
plus reveals two things the model did not have.

Not every character recurs. A subset persist across books and the rest
are local to one, and persistence is *declared* rather than inferred -
which turns out to bear directly on a data quality problem we already
knew about. The three-way speaker-count disagreements may be partly
different characters who share a name: "The Big One" in book one is not
necessarily "The Big One" in book two. Declaring persistence gives a way
to tell those cases apart instead of reconciling them into one row. I-A
holds for every character; I-B is only meaningful for a persistent one,
and vacuously true otherwise, which is correct rather than unmodelled.

The freeze is anchored once, per character, at the release of the first
book they appear in - not per book and not per series. New invariant
I-G, which is stricter than I-F for a recurring character and identical
for a book-local one.

Capture is selective, which has one sharp edge. If not every character
needs every aspect, then a missing personality artifact means one of
three quite different things: not attempted, attempted and not
applicable, or attempted and failed. Only the third is a defect, and a
nullable model cannot tell them apart - so it reports a healthy system
as incomplete or an incomplete one as healthy. Absence is therefore
typed, not null, and the validator treats in_progress as never
satisfied.

Published editions change, and a correction, a new cover or a new
foreword is a new release rather than an edit. So editions sit between
the work and the artifacts, and projects.status = released moves onto
the edition: the work is not released, it has releases. Content-
addressing makes this nearly free - an edition that fixes a paragraph
mostly reuses the same artifacts by hash, and only genuinely changed
content becomes new. That resolves the obvious tension with the freeze:
a correction in edition 2 that contradicts edition 1 is handled by a
new attribute state only later editions reference, which is I-H.

The freeze unit is a single work, so book 1 can be released while book
2 is still being written.

That leaves one question the answers created rather than closed: a
book-local character revealed to recur is two entities for one person,
and merging them would alter attributes I-G has frozen. Forward-only
link, or merge with a snapshot? Recommended the forward-only link,
because the freeze is what protects reproducibility and the gain from a
merge is retrospective. It is a question about the fiction.

Two new validator phases: persistence and partial-capture, where a
mismatch in declared persistence is a blocker and disagreement in counts
is the expected finding; and an edition probe, where unchanged content
must appear with the same hash across editions.

Two lines in 1.5.2 still described the older per-work freeze and were
corrected rather than left to contradict the new anchoring.

670 lines, 15 decisions, 9 validator phases, 8 invariants each checked
to have a home, 2 questions open.
2026-10-02 18:55:03 -07:00
umbra
21d0949b84 Character attributes change over the story: the largest model change yet
Four more decisions, and the last one reshapes the model.

Personality is one thing per character: global across books and
language-neutral, because the disposition the reader meets should not
change with the language or the situation. Scope is the character alone,
so `lang` is not part of a character_profile's identity. A translation
that cannot carry the disposition is a finding about the performance, not
a second personality - and modelling it as a second personality would
make exactly that inconsistency undetectable.

A voice is a matrix of parameter values, and a variant is a small
documented change to that matrix. That answers the scope question better
than anything the document proposed: same identity, different values,
difference recorded as data. New invariant I-E, because a variant that
changes an undeclared value is the failure mode.

The large one: a work is mutable until publication and frozen after it,
like software before deployment. Character attributes are defined in the
first work and change only explicitly - John goes through trauma in book
one, and in book two the situation costs him a leg. So attributes are
time-scoped and changes are events, never silent edits.

The trap here is one `characters.attributes` column, and the operator's
own two answers already separate what it would have merged. Disposition
is global and stable; circumstance is scoped to the timeline. The
amputation is circumstance, and whether it alters disposition is an
authorial judgement recorded as an explicit change, never a consequence
derived automatically. If the two classes shared a table, those rules
would collide in the schema and someone would resolve it by re-deriving
personality from circumstance - destroying precisely the consistency
that was designed. Three classes: identity, disposition, circumstance.

The freeze needed no migration: `projects.status` already carries
`released`, which is exactly the release boundary described. Added
`series_works` for the ordering, `character_attribute_defs` for the
classes, `character_attribute_states` append-only with an effective
position, and `voice_matrix_values` plus `voice_variant_deltas`.

Six invariants now, each checked to have a home rather than being an
aspiration with no column behind it. Two new validator phases: a timeline
probe where a circumstance change altering an accepted personality is a
blocker, and a voice matrix probe for undeclared variant changes.

One question left, because D9 says "a set of items must remain the same"
without naming the set. We assumed the minimum that makes the rest work
and flagged the assumption as load-bearing: do released character
attributes freeze, or only the released rendering? Does a published
edition ever change - is there an edition concept? And is the freeze unit
a single work or the whole series?

Corrected two stale rows now that D1 is answered: one still said
DECISION-1 was unratified, and one still offered it to them.
2026-10-02 18:41:12 -07:00
umbra
838733d51d Data model alignment: six decisions recorded, character model rewritten
The alignment document for the audio system now carries what the
operator decided, and the character model is materially different from
the one I first wrote.

The correction that mattered: I had framed speaker and author as two
names for one thing that might get conflated. They are not adjacent,
they are a chain, and the expensive trap is a third thing. A character
may be an author *inside the fiction* - a character who writes a book
within the story. That is the character's occupation and it does not
reclassify them; the book author can make a character an author and the
character remains a character. So "author" appears in two unrelated
places - the byline on a work, and a job inside a story - and merging
them puts every in-story author on the credit pages.

Decisions recorded: the controller is authoritative for data but does
not specify algorithms; compute and capability design is deferred to its
own phase and does not block this one; a character may belong to
several series; a character has an LLM-derived personality; a
character's voice is consistent across every utterance and every book;
and a character may have additional voices, each designed, captured and
stored with a reason.

Three things fell out of that which the model did not have:

- Personality is LLM output, so it is an artifact with lineage, not a
  field. Re-analysis creates a version; the accepted one is a pointer.
- Speaker is derived - a character with lines in a work - so it is a
  flag on an appearance, not an entity. Modelling it as an entity is
  what forces every language to have its own cast.
- Voice variants are passage-shaped, not character-shaped. All three of
  the reasons given are scoped to a run of utterances, so a variant
  needs a range or it silently becomes the character's voice everywhere.

Four invariants now have a home, checked rather than asserted: one
primary voice per (character, language); that primary voice keyed so no
book can change it; no variant without a reason; personality never
edited in place.

Corrected a stale row in section 4 that still named the old table
scheme, and re-checked that no invariant is an aspiration without a
column behind it. Nine new tables are required and none is claimed as
existing.

Six of the open questions are answered and four remain, three of them
sharper now: whether personality is per character or per language,
whether it is global or genuinely differs per book, what a variant's
scope is expressed in, and whether a source revision belongs to a
project or to a series.
2026-10-02 18:12:07 -07:00
umbra
a032e4f542 Record the GPU boundary as measured, on all three accounts
Fourth consecutive boot with all five nodes 0660 root:video, the timer
firing, and the unit's journal showing the helper working - so the
mechanism holds.

What was still inference is now measurement. ollama and umbra-poc both
report the RTX 3060, and umbra - which runs the controller - is refused.
The device bits and the video memberships were identical to earlier boots
where nvidia-smi had been run under those accounts, but identical
configuration is not an observed result.

Worth noting how this was reached: nvidia-device-access.sh show had been
reporting 'could not check' for the two non-root accounts since the
reboot, because there was no cached sudo credential. That is the honest
answer, and it is why the direct run mattered - the tool had been
accurately describing its own blindness while the boundary was being
claimed from configuration alone.
2026-10-01 19:35:53 -07:00
umbra
de08cfe38e Stop rate-limiting the deployment: any finite budget is exhaustible
The GPU permissions survived a third consecutive boot - all five nodes
660 root:video - so that is now established rather than hopeful.

The deployment was down again for the same reason, and this time the
widened start limit I committed two boots ago turned out not to have
fixed it. It exhausted twenty restarts and returned to the same permanent
failed, with the port free and nobody touching the service.

That fix's verification missed it. The port was freed after three
restarts, inside the window, so the test exercised the easy case and the
commit said "fixed". The real case is a fault that outlasts the budget,
which is the only case that matters.

The rate limit is the wrong mechanism, not the wrong value. Any finite
budget can be exhausted by a fault that lasts longer than it, and the
failure is silent and permanent - `failed` looks like a decision rather
than a give-up. StartLimitIntervalSec=0 disables it: the unit retries for
as long as the fault lasts and binds the moment it clears.

Verified against the case that broke it twice. Held the port for 200
seconds, which drove 31 restarts - past the old limit of 20 - with the
unit never reaching failed. Freeing the port gave 200 from /api/health
within eight seconds, with nothing having started it. A genuine crash
loop now logs every 5s instead of stopping: noisier, and much better than
a deployment that is quietly down.

469 pass with a database, 428 without. Deployment healthy, GPU boundary
intact, database unchanged at users=2 orgs=3 projects=0 audit=43 with no
compute or datastore tables, ollama serving, persistenced up, perms timer
firing, zero stray agents.
2026-10-01 07:11:13 -07:00
umbra
1dbf5fb384 The GPU permissions survive a reboot, and the deployment stops giving up
Second boot since the permission fix, and it held: all five nodes
0660 root:video, the timer firing on schedule, and umbra - which runs the
controller - still refused the card. That claim is now an observation
rather than an inference.

The same reboot surfaced an unrelated fault worth fixing. The deployment
did not come up: a `python3 -m http.server` from a development session
held port 8442, serving web/ - which the backend mounts itself and does
not need - and the service failed five times in ten seconds. With
StartLimitIntervalSec=120 and StartLimitBurst=5 that exhausted the budget,
and systemd then never tried again. The deployment stayed down after the
port was free and needed someone to notice, which is the second reboot in
a row to end this way.

A transient condition had become a permanent outage. Widened to 500s and
a burst of 20, which keeps retrying across a slow boot or a settling
reverse proxy while still stopping a genuine crash loop.

Verified by holding the port with a squatter and watching: the unit
cycles active/activating without giving up, and the instant the port is
freed the next retry binds and /api/health returns 200. My first attempt
at this test was void - the squatter could not bind because the service
still held the port, so it proved nothing.

Note that two consecutive reboots have been taken down by a background
shell in this session holding the deployment's port. It returns after
each boot. Stopping that shell, or moving it to another port, is the
durable fix; killing it again only defers it.

469 pass with a database, 428 without. Deployment healthy, database
unchanged at users=2 orgs=3 projects=0 audit=43 with no compute or
datastore tables, ollama serving, agent account intact, zero stray
agents.
2026-10-01 06:45:17 -07:00
umbra
4937b9beed Record that the GPU permissions now hold, and how that was verified
Cleared the unit's stale active(exited) state so the corrected version
could run, then broke the permission deliberately the way
nvidia-persistenced does and watched the timer repair it: 666 -> 660
root:video, with the helper reporting 'restricted 5 node(s)' in the
journal.

ollama and umbra-poc can measure the GPU; umbra, which runs the
controller, cannot. Deployment healthy, ollama serving on 11434, database
unchanged.

Records what is still unverified: no boot has been observed end to end
since the change. The unit, timer and udev hooks are installed and
exercised individually, and the persistenced interaction is handled by
ordering rather than luck, but 'works after a reboot' is a claim until a
reboot confirms it. show answers it in one command.
2026-10-01 06:33:32 -07:00
umbra
0ab1f0f1de The GPU boundary did not survive a reboot, and neither did the fix for it
This is the verification I said could not be done until the machine
rebooted. The reboot proved the boot path wrong, and uncovering why took
three rounds.

**The boundary was gone.** /dev/nvidiactl came back 0666 root:root, so
the controller could open the card again - the exact thing the udev rule
was applied to prevent.

**Why: the unit ran before anything existed.** umbra-nvidia-devices ran
at 05:38:24, ordered After=systemd-udev-settle, globbed /dev, found no
nodes, and exited 0. udev-settle means "udev has finished its queue", not
"the proprietary driver has finished initialising". The nodes appeared
moments later at 0666 and nothing revisited them. The helper ended in an
unconditional `exit 0` - I had written that deliberately, so an early boot
would not fail a unit - and it is precisely what made "I fixed the
permissions" indistinguishable from "there were nothing to fix".

**Why the first repair also failed: nvidia-persistenced resets the mode.**
It chmods /dev/nvidiactl back to 0666 on every initialisation, via
nvidia-modprobe. That is out of udev's reach entirely, because udev does
not manage these nodes. So the fix is no longer an ordering problem but an
eventual one: the unit now runs After=nvidia-persistenced.service, and a
two-minute timer re-asserts the permissions because the trigger that
breaks them can fire at any time.

**Why the timer did nothing either.** RemainAfterExit=yes on the oneshot
keeps it "active (exited)", so systemd treated every activation as already
satisfied: the timer fired on schedule, the service reported Result=success
and ExecMainStatus=0, and the permission was still wrong two minutes later.
That is the original bug repeated one level out, inside the mechanism built
to repair it. Removed, and a test now pins it - by directive name, with
comments stripped, since the unit documents the omission by naming it and a
substring search failed on the explanation.

**I also broke nvidia-persistenced.** It drops to uid 122 with setuid and
without initgroups(), so it never receives the video supplementary group and
failed with "Insufficient Permissions" once the nodes were 0660 root:video.
A systemd drop-in runs it as root - upstream's own default - which works
without widening the device permissions. It was running fine before.

The helper now waits for the nodes, verifies the modes afterwards, and
exits non-zero under --strict rather than reporting a no-op as success. It
takes UMBRA_NVIDIA_DEVDIR so its three paths can be tested without root and
without a GPU; the first attempt at that test pointed CANARY at a
non-existent path and still exited 0, because the fix loop globbed /dev
directly and the override only reached the wait.

Also: nvidia-device-access.sh `show` reported "cannot measure the GPU" when
a cached sudo credential had simply expired - a finding about the machine
when the truth was that the tool could not ask. It now distinguishes no from
could-not-check, because I was misled by it during this check.

**Still outstanding, and I could not finish it.** The repository is
correct; the installed state is stale, because clearing the unit's active
(exited) state needs root and that authorization was declined. As of now
/dev/nvidiactl is 0666 and the controller can open it. To finish:
  systemctl stop umbra-nvidia-devices.service
  systemctl start umbra-nvidia-devices.service
  stat -c '%a %U:%G %n' /dev/nvidiactl
and expect 660 root:video.

Otherwise the system came through the reboot intact: the deployment was
down only because a leftover `python3 -m http.server` from an earlier
session was holding port 8442, and the backend mounts the same files
itself. Deployment healthy, database unchanged at users=2 orgs=3
projects=0 audit=43 with no compute or datastore tables, ollama serving,
agent account intact, zero stray agents. 471 pass with a database, 430
without.
2026-10-01 06:23:21 -07:00
umbra
1ab9c2595d Verify the datastore policies as a role that does not own the tables
They had never been observed to refuse anything, and until now every
statement this project made about tenant isolation was a statement about
the adapter's own WHERE clauses.

The reason was structural. RLS does not apply to a table's owner until
FORCE ROW LEVEL SECURITY, and every connection here is the owner. A probe
run as the owner showed organization B's administrator successfully
updating organization A's imprint - exactly what WITH CHECK was written
to prevent. A policy that cannot refuse is indistinguishable from a policy
that is absent, which is why the previous commit said so plainly rather
than claiming the migration was verified.

test_rls_tenancy.py runs as rls_probe, a role with plain table privileges
and no ownership. Eight tests, each checked against a mutation: each
organization sees only its own artifacts, an unattached principal sees
nothing on any datastore table, a publisher is readable across tenants but
writable only by its owner, a cross-tenant UPDATE changes zero rows, and a
cross-tenant INSERT is refused. Two of the eight also assert the positive
cases, because a policy that refuses everything would satisfy every
refusal above.

Two behaviours asserted so a change is noticed: a filtered UPDATE or
DELETE raises nothing and simply affects zero rows, while an INSERT
violating WITH CHECK raises InsufficientPrivilege. The first is a trap
for application code that checks "did it raise?" instead of "did it
change a row?", and it is why the earlier manual probe reported a
cross-tenant update as "allowed" when the statement had matched nothing.

Two mistakes in the tests, both worth recording. A policy violation aborts
the transaction, so the second refusal in a test could not even be
attempted and reported as "DID NOT RAISE" when nothing had run; each
assertion now gets a savepoint. And a module-level pytest.skip() is a
collection error rather than a skip, which broke the whole suite instead
of quietly omitting the file - the guard now evaluates to a boolean.

This does not close gate G2. The application still connects as the owner,
so in the running system these policies still enforce nothing. What is
established is that they are correct, which makes FORCE ROW LEVEL SECURITY
plus a non-owner application role a configuration change with a
known-good target rather than an untested hope. README.md says exactly
that, and the gate stays open.

Creating the role needs superuser and was authorized this time:
  sudo -u postgres psql -d umbratrans_scratch -c \
    "CREATE ROLE rls_probe NOLOGIN; GRANT rls_probe TO umbra;"

465 pass with a database, 424 without. Deployment untouched: users=2
orgs=3 projects=0 audit=43, no compute or datastore tables.
2026-09-30 22:19:01 -07:00
umbra
5fc38d4708 The datastore, and two parties that never sign in
Compute capability is not LLM work. It is mechanical split, analysis,
text to speech, PDF formatting and audio analysis, assigned as needed,
with the code on every node. That needs a capability vocabulary rather
than VRAM - which is noted and not yet built - and, more urgently, a
place to put the output. jobs.result is a jsonb column: right for
{"echo": "hello from the queue"}, wrong for "the English audiobook, four
hours twelve minutes, derived from these thirty-seven chapters and this
cast".

So 0003 gains section 5: artifacts, job_artifacts, publishers,
project_publishers, authors, project_authors, and eight permission
keys. Folded in here rather than deferred to 0004 because 0003 has still
never been applied to the deployment, and an unapplied migration can
still be edited.

The word "account" is now absent from every new column, table and
comment. It has meant two incompatible things in this system - a person
who signs in, and a house a work is published under - and a schema
using one word for both will use it for both in one constraint. An
organization owns the work. A user is a worker and owns nothing. A
publisher is the party a work is published under and has no password, no
session and no row in user_roles, because a name that appears on a work
is not a party that can be asked to prove who it is.

A byline is a third party and is neither. In Umbra -> WorstAuthor ->
B.D. Murphy, WorstAuthor is the publisher and B.D. Murphy is the
byline; different lifetimes, and the spec had been calling both "brand".
project_authors carries a credit_role rather than a single author_id,
because the author of the English edition is the translator of the
French one and the narrator is frequently neither.

owner_organization_id is the one deliberately cross-tenant column, and
the policies are asymmetric about it: reading a publisher is open to any
authenticated principal because publishers are shared reference data,
while setting that column is refused to anyone but a platform
administrator. There is no second policy carving that out, because
PostgreSQL combines permissive policies with OR and an "admins may also"
policy widens access for everyone. The WITH CHECK on the single FOR ALL
policy is doing that job alone.

Found while testing rather than while writing: RLS was not enabled on
four of the six new tables. PostgreSQL creates a policy on a table where
it cannot apply and reports success, so pg_policies listed them and
nothing was enforced - and a check for "does the policy exist" would
have passed throughout. test_rls_coverage.py asserts relrowsecurity
instead, and each of its six assertions has been checked against a
mutation that breaks it. One needed strengthening: dropping the
dedicated SELECT policy left the table looking fully policed, because
FOR ALL includes SELECT, so the surviving policy quietly gated reading on
organization ownership and hid shared reference data.

Also: the nvidia tests failed for want of a sudo credential rather than
because anything was wrong. They skip now, with the reason stated. A
test that fails for environmental reasons teaches the reader to ignore
red.

The policies have never been observed to refuse anything, and README.md
says so plainly. A probe as the owner showed organization B's
administrator editing organization A's imprint - what WITH CHECK was
written to prevent - because RLS does not apply to a table's owner until
FORCE ROW LEVEL SECURITY. That is gate G2, not a new problem. Testing it
for real needs a non-owner role and superuser; the request was not
granted, so it is not done.

455 pass with a database, 422 without.
2026-09-30 22:08:35 -07:00
umbra
c1741456e2 Apply the GPU boundary, and fix three checks that verified themselves
The udev rule I wrote last commit could not have worked. The nvidia
driver creates /dev/nvidia* in the kernel without registering them in
sysfs, so `udevadm info --name=/dev/nvidiactl` answers "Unknown device"
and udev never evaluates a rule for them - which is also why they are
0666, since nothing sets a MODE. The rule hooks the driver bind event
instead and runs a script, by which point the nodes exist.

The first version of that hook triggered 0000:01:00.0, which on this
machine is an AMD root complex, vendor 0x1022 rather than 0x10de. The
trigger fired on the wrong device, the RUN key never ran, and the
permissions looked right because the apply script had just fixed them
itself. The address is discovered now. Verified by resetting to 0666,
firing only the bind event, and watching it come back 0660 - the script
never invoked.

Applied and verified: ollama can open the card, umbra-poc can, umbra
cannot. Traced, nvidia-smi opens /dev/nvidia0, /dev/nvidiactl and
/dev/nvidia-uvm, so umbra-poc is in video or the agent starts,
heartbeats, and reports a machine with no GPU - a failure that presents
as a scheduling problem and is a permissions one. It is the agent's only
supplementary group and grants a device, not a filesystem, so setup's
"no supplementary groups" check now allows exactly video and rejects
everything else, verified by adding users and watching it fail.

Three tests, each verified against a mutation. The rule test resets the
permissions and fires only the event, so it fails if the rule is inert.
The end-state test fails rather than skips when the rule is missing -
skipping made deleting the rule quieter, three passed becoming one
passed and two skips, which reads as an improvement in the wrong
direction.

Also: the check "the controller cannot open the card" was testing root,
which bypasses permissions entirely, and reported a working rule as a
failure. The account is named now.

Then four runs in a row leaked agents, and the cause was upstream of the
leak. The victim reused the first agent's credential, and a node is
identified by its credential, not by its config's node_name - so it
heartbeated as poc-agent-node, was never offered the long job, and sat
there for the whole step while a correct liveness check reported that the
worker it had killed had survived. It has a third node and its own
credential now, so the requeued job is not immediately reclaimed by an
agent already stopped for the step.

The last leak was a worker asleep in a 600-second job, unable to see a
stop file because the check is at the top of its loop rather than inside
the work. Four runs left one each. The job is 10 seconds now: the victim
exits the instant it claims it and never runs it, so the length was
never the point. PoC node names and idempotency keys carry a per-run
suffix, so a second run against the same database does not die on a
UniqueViolation - and there is no route to delete a node.

Three consecutive runs, zero failures, zero leaks. 451 pass with a
database, 424 without. Deployment untouched: users=2 orgs=3
projects=0 audit=43, no compute tables.
2026-09-30 07:24:27 -07:00
umbra
220bc00823 An upgrade path for the agent, and the GPU device boundary
Two questions worth answering properly rather than by assertion.

How an agent is updated. `setup` has always doubled as an upgrade -
it installs the file unconditionally, which is most of what an
upgrade needs and the one thing it should not do: a broken file
replaces a working agent on every node with no way back. `update`
names the version being replaced, keeps the previous file, and
verifies that the new agent *starts as umbra-poc* rather than that
it parses. A file that imports cleanly under umbra's virtualenv may
not be readable by the account that runs it. Verified against a
deliberately broken agent: installed, detected, previous restored.

The agent does not update itself. An agent that can fetch and
execute code has a remote-code-execution channel, and the point of
the account is that it holds no credentials and reaches nothing.
Updates are an out-of-band deploy per node. Nothing enforces a
minimum agent version either - the console can report "behind", but
refusing a heartbeat would strand a node mid-job for tidiness.

The GPU. /dev/nvidia* is 0666 root:root because the driver creates
it that way and its own 71-nvidia.rules sets no MODE or GROUP.
Measured, not assumed: nvidia-smi -L succeeds for umbra, ollama and
umbra-poc alike - so the controller can open the card today, and the
only thing stopping it from loading a model on top of the node's
work is the scheduler choosing not to.

72-umbra-nvidia.rules sets 0660 root:video, sorting after the
driver's rule so it wins. video already contains ollama, which is
right: ollama is the account that opens the GPU. umbra-poc must be
added to it, and traced nvidia-smi opens /dev/nvidia0,
/dev/nvidiactl and /dev/nvidia-uvm - so without the membership the
agent starts, heartbeats, and reports a machine with no GPU. That
fails silently, as a scheduling problem rather than a permissions
one. It is the agent's only supplementary group and grants a device,
not a filesystem, so it is added by the apply script rather than by
setup.

What it does not buy: separation between two agents. Both are
umbra-poc, both in video, both could open the card. On one box with
one GPU, placement is the control and the kernel is not. What it
buys is that every other account stops being able to touch the card,
the controller above all. apply verifies the rule took, that ollama
and the agent can still measure the GPU, and that ollama still
serves models - and reverts itself if any of those fail.

Also fixes a check this session broke: a sed meant for new code
rewrote verify's agent-start line to run a command called `x`, so it
had been failing on every run and I read past it. `bash -n` cannot
see that class of damage; verify did, which is the argument for it.
2026-09-30 06:32:11 -07:00
umbra
0f5c1e3408 Run the agent as the service account it claims to be
The compute proof of concept had never been run with the agent as
umbra-poc. Running it that way found three defects, all the same
shape: a check that passed because its mechanism did nothing.

The demonstration never reached the agent at all. It held the pid it
spawned, which under `sudo -n -u` is the sudo wrapper - sudo forks the
agent before exec'ing. Signalling that killed the wrapper and left the
worker running, renewing its lease and holding the job indefinitely,
under a printed note saying "exactly like a power cut". An
unprivileged process cannot signal another account's process at all,
and kill(2) on a process group succeeds as soon as one member is
signalled, so the wrapper's death read as the agent's. Every run
leaked an agent.

Lease recovery was a diagnosis rather than a mechanism.
sweep_expired_leases documented itself as "called by the controller's
own timer" and there was no timer; a killed node's job came back only
when somebody noticed a stalled job and posted to an administrative
route by hand. The proof of concept swept by hand and recorded the
result as automatic. app.py now runs the loop for the life of the
application, building the compute repository at startup so a restart
mid-job still recovers it.

The power cut is now the agent's own: os._exit(137) the instant it
claims a job, before running it, because a process that does not own
the agent cannot signal it. A clean stop goes through a stop_file the
agent polls, for the same reason. Liveness is measured by finding the
worker's own pid in /proc matched on its config path, sampled
repeatedly and accumulated - the wrapper and the worker are not
simultaneous, so a single sample finds only the wrapper.

The node key travels by `sudo --preserve-env` rather than as VAR=value
in argv, because argv is world-readable through /proc/<pid>/cmdline
and the environment is not. The demonstration checks whether the key
will survive before starting and falls back to a pipe, which
node_agent.py --node-key-stdin accepts. Neither a file nor argv ever
holds it.

Eight tests, each verified to fail against a mutation of the code it
covers: three for the sweeper (it must return an expired lease, must
survive a failing round, must not invent a repository it has no
database for) and five driving a real agent process against a stub
controller (stops on the stop file, exits 137 holding an uncompleted
job, takes the key from both routes and neither, reports an empty pipe
as an empty pipe).

448 pass with a database, 421 without. The deployment database is
untouched: users=2 orgs=3 projects=0 audit=43, no compute tables.
2026-09-30 06:18:26 -07:00
umbra audio
1ab0fa2498 A service-account setup and boundary check for the node agent
The agent is stdlib-only - no package imports, no third-party dependencies - so
it needs no virtualenv and no access to the controller's home directory. That is
the whole reason the setup is short: an account with no group memberships at all,
running one file from /opt on the system python.

poc/poc_node_user.sh has four subcommands. setup needs root and creates the
account, installs the agent, and writes a config that deliberately contains no
key. verify asserts the boundary. run does both. status reports what exists and
what is reachable, needing no privilege at all.

The check that matters: the agent must not be able to read
~/.config/umbratrans/umbratrans.env. It cannot, and there is a second layer behind
it - peer authentication maps a unix user to a database role, and umbra-poc has
none, so even a leaked DSN would not connect. The two are independent and
neither alone is the boundary.

Writing verify produced the third instance of a bug class that has come up
repeatedly in this work: a check that passes because its mechanism failed. When
sudo -u could not run, every positive check correctly failed and every negative
check passed - because `test -r` on an unreadable file returns false, which is
exactly what "correctly denied" looks like. The script would have reported a
boundary as holding while testing nothing. It now confirms the privilege drop
once, up front, and abandons the run rather than reporting; status prints
"unknown (no privilege drop)" rather than "no". Found by running the checks
against an account it could not drop to, which is the only way to see it.

Also: the agent's config now travels in a 0750 directory, because mkdtemp gives
0700 and a different user could not have read its own config out of it.

The script is dry-run against an absent account, an existing unprivileged
account, a missing controller, and a rootless setup invocation. It has not been
run end to end, because umbra-poc does not exist yet.
2026-09-30 05:40:48 -07:00
umbra audio
d13664a607 A compute substrate: node inventory, a leased queue, and an agent
Implements §6.16-6.20 and §7 of the specification, and proves it end to end on
this box: a controller and an agent, the agent with no database credential at
all, ten checks against the properties whose failure modes are silent.

Migration 0003 adds compute_nodes, compute_devices, node_credentials,
model_registry, node_models, node_heartbeats and job_events, and creates jobs
- which §6.8 had specified and 0001 had never migrated. Every column added to
jobs is nullable or defaulted, every policy is written against the same app.*
helpers the existing eleven tables use, and re-applying is safe: CREATE POLICY
has no IF NOT EXISTS, which is how 0001 learned the same lesson for ADD
CONSTRAINT.

Four properties, and each exists because the alternative fails silently:

- **I25** claiming is FOR UPDATE SKIP LOCKED. Two agents claiming at once get
  different jobs. The proof of purpose counts the 'claimed' events rather than
  counting successes, because two agents both succeeding is not evidence.
- **I26** a lease that expires returns the job to the queue, and the proof of
  purpose SIGKILLs the holder rather than stopping it, because a clean stop
  would release the lease and a clean stop is not the failure being tested.
- **I24** a node is online because it heartbeated. Creation is not readiness.
- **I28** a node credential reaches queue operations and nothing else. The
  organization comes from the credential, so a forged tenant identifier has
  nothing to attach to.

And the one that is hardest to get right in the cheap direction: **a device
that is slow is eligible, and one that is too small is not.** A model that does
not fit in VRAM runs on system RAM, slowly, and a job nothing can run is
'blocked' with the reason rather than left looking like it is progressing.

The proof of purpose found six defects, listed in §7.8. The two worth naming
here:

- Mounting one router at two prefixes published the administrator's routes
  behind node-credential authentication, so a node could create nodes and issue
  itself more authority. Two routers now, with a test that pins the surfaces
  disjoint and the node surface exactly.
- **The correction I was given about unified memory was in the specification and
  not in the code.** Eligibility was VRAM-only, so every CPU and every
  shared-memory machine was ineligible, which would have reduced the fleet to
  one machine. Writing the implementation is what kept the error alive.

Also: the CSRF middleware blocked the whole node API, because a
bearer-authenticated agent has no ambient cookie for a cross-site request to
ride on. Exempted by prefix, with a test asserting that no cookie-authenticated
route joined it.

419 tests pass with the integration suite against a real database. The
deployment database is untouched and has no compute tables.
2026-09-29 20:09:36 -07:00
umbra audio
bfde763f00 Fix a 500 on /api/health from an uninterpolated SQL placeholder
The first deploy of the previous commit took the health endpoint down. The
tenant-row predicate had been added to `count_users_with_role` as a literal
`{TENANT_ROW_PREDICATE}` inside a plain triple-quoted string rather than an
f-string, so the braces were sent to PostgreSQL as text and the statement failed
to parse. That query backs `setup_is_required`, which every unauthenticated
health probe calls, so `/api/health` returned 500 and the deployment reported
itself broken.

Nothing caught it before production, and the reasons are worth keeping:

- the unit tests use the in-memory repository, which has no SQL;
- the integration tests did not call `count_users_with_role`;
- and the source-level test I wrote to pin the predicate's presence was
  satisfied just as well by the broken version, because the broken version does
  contain the text. A test that checks a predicate was *written down* cannot tell
  a working predicate from an inert one.

Two tests now, each catching the regression on its own:

- `test_no_sql_string_carries_an_uninterpolated_placeholder` walks the parse tree
  rather than the text, so a `{...}` in a non-f-string fails whether or not the
  surrounding code mentions the constant. It reports the offending query, not a
  line number: `inspect.getsource` returns a class body lifted out of the file, so
  its line numbers do not point anywhere a reader could open.
- `test_the_health_probe_queries_run_against_the_real_schema` runs the probe's
  own queries against the real schema, and
  `test_setup_is_required_ignores_project_assignments` holds the predicate's
  purpose: a project row is never mistaken for a platform administrator.

413 tests pass with the integration suite; 388 without a database.
2026-09-28 11:47:13 -07:00
umbra audio
ed72f61975 Separate organizations, and give members their organization's projects
An organization's work is separate from every other organization's, and the
separation is now one module rather than a property each route remembers.

`tenancy.py` resolves the caller's scope in a single place. A member is pinned
to the organization on their own assignment, expressed as a concrete identifier
rather than a flag, so "no filter" is unreachable except for a platform
administrator. Every read and write resolves through it, so there is no per-route
variation to drift.

Within an organization, every account sees every project. The brief is explicit
that two people working on different books both see both, and assignment says
who is *working* on a project rather than who may *know* it exists. This
diverges from `app.can_access_project`, which restricts visibility to assigned
members; the divergence, and what it means if row-level security is ever
switched on, are recorded in migrations/README.md because that would otherwise
be a silent tenant-internal regression no test here would catch.

Two rules, both learned by getting them wrong first:

- A read naming another organization is ignored, because refusing it would
  confirm the identifier exists. A write is refused rather than retargeted,
  because a project landing in an organization the caller did not ask for is
  somewhere they will not look.
- A missing project and a foreign one get the same error, so an identifier cannot
  be probed across a boundary.

Projects: the `projects` table, its statuses, and the project-scoped arm of
`user_roles` were already in the schema and unused. No migration.

The dashboard is one server-scoped call, `/api/workspace`, rather than several
assembled in the browser — pieces from different tenants that nobody notices are
not a failure mode worth shipping. Filters narrow the list already in the page,
so switching cannot mix two fetches, and an empty list is distinguishable from a
failed one.

**A sign-in lockout, found by the integration suite.** An assignment is a second
active `user_roles` row, and `_resolve_assignment` refused an account with more
than one. So assigning anybody to anything made them unable to authenticate —
locked out of the page the assignment was meant to show them. `_sync_assignment`
had the same defect. Six queries were scope-blind; three were harmless only
because the assignment role is named `viewer` and never matched a tenant role
name, which is luck rather than a property, so a structural test now pins the
predicate on every one. The guard against two *tenant* rows survives, narrowed to
the ambiguity that actually matters.

Also fixed: `get_project_by_slug` cast `slug::text`, making citext's
case-insensitivity a case-*sensitive* comparison that appeared to work everywhere
else. An assignment grants the `viewer` project role, since no `member` role
exists at project scope; it is a named constant, not a query literal, because the
project workflow is not defined yet.

410 tests pass with the integration suite against a real database; 386 without.
2026-09-28 11:34:37 -07:00
umbra audio
b12c702c66 Show whether an account's address is confirmed, and let an admin re-send
The accounts page now has an Email column carrying the address and its
verification state. A confirmed address shows a plain marker; an
unconfirmed one shows a button that sends another confirmation link.

The field was already on the response, and it was lying.
AccountResponse.is_email_verified defaults to True and from_view never set
it, so every account in the list reported itself as confirmed, invited
ones included. A badge that always reads "Confirmed" is worse than no
badge, and about to hang a re-send control off it made that worth fixing
first. It is passed explicitly now, with a test covering an invited
account.

The address moves out of the Account cell into its own column, because the
verification state is a fact about the address and reads better next to
it than stacked under a name.

The badge is a button only when unverified. Offering to re-send a
confirmation for an address that is already confirmed would imply a
pending one might exist, and would put a live control on every row for no
reason.

The re-send posts to the endpoint the sign-in page already uses, which
answers identically whether or not an account is awaiting confirmation.
That is deliberate: the console holds a list of accounts, so "is this
address verified?" is exactly the question that should not be askable of
an arbitrary address, and an administrator-specific endpoint would answer
it precisely. A test asserts the two responses are byte-identical.

Verified end to end against a scratch database: an invited account
renders as "Not confirmed", the control sends a verification link rather
than a reset link, and an address with no account produces the same
message.
2026-09-28 06:25:38 -07:00
umbra audio
fa781cf708 Stop the reset page calling a dead link when it cannot reach the service
The reset link was reported as "no longer valid" while it was still live.
The token in the store was unconsumed and had not expired, so the link was
fine and the page was wrong.

Two mistakes, and the second is the one that mattered.

The first was a double question mark. passwordResetAvailable did
'/api/auth/reset-password?' + buildQuery(...), and buildQuery already
returns a leading '?', so the request went out as:

    GET /api/auth/reset-password??token=...   -> 422

The server read an empty parameter name and rejected the request, which is
correct. Now the call site appends buildQuery's output instead.

The second is why it took this long to find. The page had one failure
path, so every outcome became "this link is no longer valid": a malformed
request, a 500, a dropped connection, and a genuinely expired link were
all reported the same way. That is a confident false statement to make to
someone holding a good link, and it is exactly what happened.

There is now a third state, shown when the service could not be asked. It
says the check could not be completed and that this is not the same as the
link having expired, because it is not. Only the server's own "not
available" answer claims the link is dead, which is what keeps the
non-enumeration property intact: the page still cannot be used to learn
whether a link ever existed.

Two tests. One asserts no request site adds a '?' to buildQuery's output,
and that the named method uses the right shape; reverting the fix fails it.
The other asserts the unreachable state exists and is distinct.

The token from the earlier attempt is still valid until 06:57, so the same
link can be used once this is deployed.
2026-09-28 06:03:01 -07:00