Compare commits

..
22 changed files with 641 additions and 416 deletions

No files matched your search

-402
View File
@@ -1,402 +0,0 @@
# Row 255 design: a bundled Pocket ID for users with no OIDC provider (Q203, Gitea #1760, Vikunja 458)
Launchpad, draft 1, 2026-10-25, on `launchpad/row255` off refactor 78b69e1e6. Brief
`docs/plans/2026-10-21_bundled-pocket-id.md` (blob 26f34400). Reviewer Webby. Sage checks it against Q203, O6,
REQ-WEB-2 and REQ-INST-1. REQ-INST-2 (PRD 1.10) is post-v1 (mos-1204), and so is everything here.
This is a design only. Nothing here installs, downloads or starts Pocket ID. It changes nothing on .53, .54 or
dragon-lin, which keeps Authentik.
## Sources
- Q203 and O6 as the brief quotes them. PRD `docs/prd/mosaic-stack.md` REQ-WEB-2 (:902), REQ-INST-1 (:1509),
REQ-INST-2 (:1513) and REQ-INST-3 (:1528). Lead decision 44 items 5 and 6
(`docs/plans/2026-09-26_lead-decisions.md:655-663`).
- The Q175 design `agents/sage/work/q175-sso/DESIGN.md` sections 4 and 10, and the code row 192 landed:
`packages/webui/src/sso-config.mjs` (the `console.sso` shape), `packages/webui/src/sso.mjs` (the slot read,
the discovery checks at :105-118, the client at :147-148, the callback at :133, the post-logout URI at :332).
- Researcher's note `agents/researcher/work/2026-10-04_vikunja-pocketid.md` part 2 (v2.17.0).
- A fresh read of upstream on 2026-10-25, from release metadata and source text only: no binary was fetched or
run. The latest stable release is **v2.19.1** (2026-10-22). Source paths below are under
`backend/internal/` at tag v2.19.1 in `github.com/pocket-id/pocket-id`, marked "P". Releases v2.18.0,
v2.19.0 and v2.19.1 are not marked breaking.
## What the Console already needs from any provider
This design keeps the Console's sign-in and start checks exactly as row 192 built them. Pocket ID becomes one
`console.sso.providers` entry, the same shape any compliant OP uses (Q175 section 4, "Several providers"). The
Console gets no new sign-in path, no password and no proxy-header identity (O6, REQ-WEB-2, REQ-INST-2). The
facts that drive the rest of the design:
- `publicOrigin` and every `issuer` must be https (`sso-config.mjs` `httpsURL`). A plain-http Pocket ID can't
be a provider at all.
- Each provider needs 1 to 64 `subjects`. A `console.sso` with no subject refuses to load. So the first
person's subject has to exist before the Console starts with `--sso` (section 5).
- The client authenticates with `client_secret_basic` (`sso.mjs:148`), so the client is confidential.
- The slot is `<configDir>/secrets/mosaic-stack/<slot>`: a regular file, owned by the Console's uid, mode
with no group or other bits, and outside the repo and every stack copy (`readSlot`).
- `config.json` is user-authored. bootstrap.sh creates it and nothing overwrites it (canon invariant 2, Q175
section 4). This matters for sections 1 and 5.
## 1. The choice
The installer is REQ-INST-3's install command (Vikunja #432; today `stack-upgrade.sh` and the per-case
install scripts). It takes one sign-in option per instance:
- `--oidc existing`. The user points the Console at one or more OIDC providers they already have. Nothing
is installed. The user writes `console.sso` themselves, as Q175 step 4 does today. The installer writes
nothing into `console.sso`; it prints the entry shape and the two URIs to register
(`<publicOrigin>/auth/callback`, `<publicOrigin>/auth/signed-out`).
- `--oidc bundled`. The stack installs Pocket ID as an install component (section 2), and
`console.sso.providers` holds exactly one entry, `id: "pocket-id"`, whose `issuer` is the component's
origin.
- No option keeps today's behaviour: no `console.sso`, and the Console runs without `--sso`.
How the bundled choice is recorded. REQ-INST-1 lets a component be switched on "in the organization file
or system config". It has to be the system config. The organization file can be written by agents through
`organizationWriter`, which is the reason Q175 B1 put `console.sso` in config.json, and the same reason
holds for a component that issues the Console's identities. So config.json gets one new optional key:
```json
"components": {
"pocket-id": {
"release": "v2.19.1",
"origin": "https://id.example.internal",
"port": 1411
}
}
```
The loader (`scripts/mosaic-config.mjs`) checks the shape: `release` is a `vX.Y.Z` tag, and the component script refuses one that
differs from the pin file (section 2), `origin` is an https origin, `port` is 1024 to 65535. It also cross-checks: when
`components["pocket-id"]` is present, `console.sso` must have exactly one provider, `id` `pocket-id`,
`issuer` equal to `origin`. A config with `console.sso` providers and no `components` is the existing
choice and loads as today.
**M1, approved by Mos with three conditions (mos-1666 item 3).** `components` is a new top-level config key.
The loader refuses unknown keys today, so this is a config schema change. The conditions:
1. **Rollback trap.** A copy older than P1 refuses a config that has the key, the same trap Q175 section 4
names for `console.sso`. P1 documents the order. On a rollback past P1, first take `components` out of
config.json (restore the backup made before the key went in), then roll back the pointer. The STATE check
after the rollback reads config.json and confirms the key is absent.
2. **Strict inside the key.** The loader refuses any name under `components` other than `pocket-id`, and any
field under `pocket-id` other than `release`, `origin` and `port`. Each refusal names the key path and
quotes no value, as `checkConsole` does.
3. **test-config.sh cases:**
- a valid `components` loads;
- a malformed one fails, naming the bad key path;
- a config without `components` loads;
- the cross-check refuses a `console.sso` whose provider doesn't match `origin`.
What each choice writes into `console.sso` is in section 5. For the bundled choice, Pocket ID is the only
providers entry. The design keeps it that way because REQ-INST-2 says "the Console's one providers entry".
A user who later adds an existing provider moves to `--oidc existing` and removes the component (section 2).
## 2. The component, against REQ-INST-1
**Pinned.** A Pocket ID release is a set of raw binaries plus `checksums.txt` (sha256). From v2.16.0 there is
an SPDX SBOM per binary, and the release workflow attests `checksums.txt` through Sigstore (`actions/attest`
in `.github/workflows/release.yml`). The repo holds one pin file,
`scripts/pocket-id-pins.sh`, sourced the way `scripts/case1-pins.sh` is: the tag, and for each supported platform (linux amd64 and arm64 to
start) the asset name and its sha256. The build row copies the sha256 from `checksums.txt` at the tag and
records the `gh attestation verify` output for each asset as its evidence. The pin is checked by hand at
build time, not trusted at install time. No Pocket ID source and no binary goes in the repo. The binary is
not in any image either, since the container install comes after v1 (REQ-INST-3).
Recommended pin: v2.19.1, the current release. The build row checks it again, because Pocket ID ships a
minor every one to four weeks. Upgrading is a pin file change in a reviewed commit.
**Fetched and checked.** `scripts/component-pocket-id.sh fetch` downloads the pinned asset for the host's
platform over https into a new 0700 staging directory, computes its sha256, and compares it with the pin.
On a mismatch it deletes the file and refuses with `pocket-id-digest-mismatch`, naming the expected value
and the computed one. On a match it installs the binary read-only (0555) at
`<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`. The fetch is the only network step, and the user
runs it at install. Tests never run it against the network (section 9).
**Runs.** A systemd user unit, `mosaic-pocket-id.service` (`mosaic-pocket-id-NAME.service` for a named
instance, as row 190 names the bus and webui units), as the instance's user:
- `ExecStart=<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`.
- `WorkingDirectory=<dataRoot>/components/pocket-id/run`, a directory the unit owns, so Pocket ID's habit of
reading `.env` from the working directory reads nothing we didn't write. The build row checks there is no
`.env` there at start.
- Environment, all from the rendered unit:
- `APP_URL=<origin>` and `HOST=127.0.0.1` (P `common/env_config.go`: HOST defaults to `0.0.0.0`).
- `PORT=<port>`.
- `DB_CONNECTION_STRING` and `UPLOAD_PATH` as absolute paths under `<dataRoot>/components/pocket-id/data/`
(P defaults are `data/pocket-id.db` and `data/uploads`, relative to the working directory).
- `ALLOW_USER_SIGNUPS=disabled` and `ALLOW_INSECURE_CALLBACK_URLS=false`.
- `EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED=false` and `..._AS_ADMIN_ENABLED=false`.
- The `OUTBOUND_ALLOWED_HOSTS_*` allowlists set empty (added in 2.19.0).
- No `TRUST_PROXY` unless section 6's proxy step sets it to the proxy's address.
- `ENCRYPTION_KEY` (mandatory since v2.0, at least 16 bytes) never goes in the unit text. It lives in slot
`<configDir>/secrets/mosaic-stack/pocket-id-encryption-key`, 0600. The component script generates it from
`/dev/urandom` straight into the file and never prints it. The unit passes it with
`LoadCredential=` and `ENCRYPTION_KEY_FILE=%d/...`. The build row confirms `ENCRYPTION_KEY_FILE` at the pinned
tag; if it isn't there, it refuses rather than putting the key in `Environment=`.
- The unit never carries `STATIC_API_KEY`. That key exists only during the enroll run (section 4).
The data directory holds the SQLite database, Pocket ID's signing key (stored encrypted with
`ENCRYPTION_KEY`), and the passkey registrations. It is 0700. Agent sandboxes bind only an allowlist
(`packages/seat/src/sandbox.mjs`), and `<dataRoot>/components/` is not on it. The build row adds a sandbox
test that pins that.
**Health-checked.** `GET http://127.0.0.1:<port>/healthz` returns 204 (P `controller/healthz_controller.go`).
`component-pocket-id.sh status` reports the unit state, the binary tag against the pin, the binary's sha256
against the pin, and the health result. The Console's own `/healthz` already shows `sso: { "pocket-id": ready
| unreachable | refused }` from its discovery retries, so an operator sees the provider from either side.
**Removable.** `component-pocket-id.sh remove` refuses while `console.sso` still names `pocket-id`, so the
Console can't be left pointing at a provider that's gone. Once the user has taken the entry out (or moved to
`--oidc existing`), the script stops and removes the unit, then moves the data directory to
`<dataRoot>/components/pocket-id.removed-<utc>` with a receipt line in `<dataRoot>/state/components.log`
(append-only). It never deletes the passkey database outright. Deleting the moved directory and the
encryption key slot is a separate `purge` verb that names both paths and needs `--yes`. Removing the
`components` key from config.json is the user's edit.
## 3. The Q175 start checks against the bundled instance
The Console checks are unchanged. Here is each one against Pocket ID, and the setting it needs:
| Check (row 192) | Pocket ID v2.19.1 | Default that fails | Fix |
|---|---|---|---|
| `issuer` is https (loader) | issuer = `APP_URL` exactly (P `controller/well_known_controller.go`) | `APP_URL` defaults to `http://localhost:1411` | `APP_URL=<https origin>`, section 6 |
| discovery `issuer` equals config `issuer` | equal when `APP_URL` has no trailing slash; Pocket ID refuses a path | none | config `issuer` = `origin`, no trailing slash; the loader cross-check compares them |
| `id_token_signing_alg_values_supported` overlaps `algs` | advertises only the current key's alg. RS256 (RSA-2048) by default (P `service/jwt_service.go`) | none | `algs: ["RS256"]` |
| `code_challenge_methods_supported` has `S256` | advertises `plain` and `S256` | none | none. The Console always sends S256 (`sso.mjs:221`) |
| JWKS has a usable key for `algs` | one key at `/.well-known/jwks.json` | none | none. The build row's fake OP pins `use`/`alg`/`kid` as Pocket ID emits them |
| `end_session_endpoint` is https | `<APP_URL>/api/oidc/end-session` | http, if `APP_URL` is http | same fix as row 1 |
| `redirect_uri` is registered | exact match first, then wildcard patterns; loopback callbacks match any port (P `utils/callback_url_util.go`) | `ALLOW_INSECURE_CALLBACK_URLS` defaults to true | register exactly `<publicOrigin>/auth/callback`, with no `*`; set `ALLOW_INSECURE_CALLBACK_URLS=false` |
| post-logout URI | per-client `logoutCallbackURLs`, same matching | none | register exactly `<publicOrigin>/auth/signed-out` |
| ID token alg in `algs`, `iss` match | as above | none | none |
Two points the Console doesn't check, set on the client anyway:
- `pkceEnabled: true`. Then Pocket ID refuses an authorize request that has no `code_challenge`. Pocket ID has
no per-client S256-only setting (P `oidc/provider.go` turns on `plain` for the server). That doesn't relax
anything: the Console sends S256 every time, and `plain` stays available only to other clients.
- `isPublic: false`, which matches `client_secret_basic`.
Key rotation. `pocket-id key-rotate --alg ...` replaces the one signing key (P `cmds/key_rotate.go`). After a
rotation to another alg, the Console's discovery check refuses on the next restart (no overlap with
`algs`), and that is the intended fail-closed result. A rotation is a user step that updates `algs` in the
same edit. The component script never rotates.
## 4. The client, and where its secret goes
The person running the install creates the client through `component-pocket-id.sh enroll`. No agent runs
this verb. It refuses when any agent marker is in its environment: the `MARKERS` list in
`packages/bus/src/human.mjs` (`MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP`, `CLAUDECODE` and the rest) that the human
boundary uses. A test pins the refusal.
Enroll needs admin rights before any person has a passkey. Pocket ID offers two ways to get them, and one of
them is a hazard:
- `/setup` and its backing `POST /api/signup/setup` are unauthenticated: whoever calls first becomes admin
(P `usersignup/service.go`). Sandboxed agents share the host's network namespace (`sandbox.mjs` unshares
user, pid and ipc, not net), so any agent could reach a loopback Pocket ID and take that first admin.
- `STATIC_API_KEY` is a fixed admin key with no expiry (P `apikey/service.go`). It doesn't count as
completing setup, so `/setup` stays open while it's set.
So enroll never lets the real unit serve before an admin person exists. It runs the pinned binary once in a
**private network namespace** (`unshare --user --net`, or `bwrap --unshare-net` with the binary and data
directory bound), where only enroll's own loopback can reach it:
1. Generate a one-run static key into a 0600 temp file in a new 0700 directory, and start Pocket ID inside
the namespace with `STATIC_API_KEY_FILE` pointing at it, plus the unit's environment.
2. Wait for `/healthz` 204.
3. Create the admin user, `POST /api/users` with `isAdmin: true` and the username and email given with
`--admin-user` and `--admin-email`. Keep the returned `id`. That is the OIDC `sub` (P
`oidc/claims_service.go` uses `user.ID`, a UUIDv4 set at creation, so it's stable).
4. Create the Console client: client id `mosaic-console-<instance>`, callback and logout URLs exactly as in
section 3, `pkceEnabled: true`, `isPublic: false`. Supply the secret instead of letting Pocket ID
generate it. Enroll generates 48 random bytes, base64url, and writes them straight to slot
`<configDir>/secrets/mosaic-stack/console-sso-pocket-id` (0600, `O_CREAT|O_EXCL`, not a symlink). It sends
the same bytes in the create body over the namespace's loopback (`dto/oidc_dto.go` accepts a supplied
secret of 16 or more characters). The value is never printed, logged or put in an argv, and a test pins
that it appears in no output.
5. Mint a one-time access token for the admin, `POST /api/users/<id>/one-time-access-token`.
6. Stop the namespaced process, then shred and remove the static key file. The real unit has never had a
static key, so no admin key survives enroll.
7. Start the real unit and wait for `/healthz` 204.
8. Print the sign-in link `<origin>/lc/<token>` to the user's terminal (section 5), and the `console.sso`
entry (section 5).
The build row confirms each route and body against the pinned tag's source before it writes the client (the
upstream read for this design didn't trace the create-client route). If any of steps 1 to 7 fails, enroll
stops the process, removes the static key, leaves no slot file it created, and refuses with the step name.
The secret never enters the repo or an image. It sits in two places only: the slot file and Pocket ID's
database (stored as a hash). The Console's `readSlot` reads it as it does any provider's.
## 5. The first person, and their subject in `console.sso`
Pocket ID is passkey-only for people (REQ-INST-2). The installing user enrolls like this:
1. Enroll prints `<origin>/lc/<token>`. The token is valid for one hour and is a credential, so it goes only
to the terminal of the person who ran enroll. It goes in no log and no file.
2. The user opens it in a browser that can reach `origin` (section 6). Pocket ID signs them in and asks them
to add a passkey. Without one they can't sign in again. If the hour passes, `pocket-id
one-time-access-token <username>` (P `cmds/one_time_access_token.go`), run by the user against the data
directory, prints a new link. `component-pocket-id.sh relink` wraps that call.
3. Enroll already has the subject, the `id` from step 3 of section 4. It doesn't wait for a sign-in or read a
`subject-unknown` log line.
How the subject gets into `console.sso`. config.json is user-authored and never auto-written. Two ways
to respect that; I recommend the first:
- **R1 (recommended).** Enroll prints the full entry and writes nothing:
```json
"console": { "sso": { "publicOrigin": "<publicOrigin>", "providers": [ {
"id": "pocket-id", "issuer": "<origin>", "clientId": "mosaic-console-<instance>",
"clientSecret": { "slot": "console-sso-pocket-id" }, "algs": ["RS256"],
"subjects": [ { "sub": "<id>", "name": "<admin-user>" } ] } ] } },
"components": { "pocket-id": { "release": "<tag>", "origin": "<origin>", "port": <port> } }
```
The user pastes it into config.json, as with Q175 step 4. Rerunning `scripts/bootstrap.sh` (or
`--instance NAME`) then validates it through the loader, including the section 1 cross-check, and writes
nothing. Nothing changes in invariant 2.
- **R2 (rejected, Sage sage-4401: R1 chosen).** `enroll --write-config` would write those two keys itself, with a backup of config.json first, and
refuse if `console` or `components` were already present. That would be the first tool to modify an
existing config.json, against canon invariant 2's "never overwritten" practice. Not built.
Whose subject. Enroll creates one person, the one running it, and only that subject goes in the entry. On
Jason's instances, only his subject goes into `console.sso` (mos-1197). Further people are added with
Pocket ID's admin UI (a user plus a one-time link), and their subjects go into `console.sso` by the user's
edit, never by an agent. Pocket ID users who aren't in `subjects` can sign in to Pocket ID, but the Console
refuses them `subject-unknown`, so REQ-WEB-2's "must map to an authorized human" holds.
## 6. Reach: origin, TLS and proxy
WebAuthn takes its RP id from `APP_URL`'s host name, and its allowed origin is `APP_URL`
(P `webauthn/module.go`). Browsers allow passkeys only on a secure origin. The Console also needs https for
both `publicOrigin` and `issuer`. Pocket ID serves plain http, and I found no TLS listener setting in its
environment config. So:
- Pocket ID always binds `127.0.0.1:<port>`. That is the default, and the component never binds anything
else (`HOST=127.0.0.1`; the status verb refuses a unit whose `HOST` differs).
- `origin` is an https origin served by a TLS front that the user already has for the Console. The Console
needs one anyway, since `publicOrigin` is https. Pocket ID goes behind the same front under **its own host
name**, for example `id.<console host>`. Pocket ID doesn't support a path prefix (`APP_URL` must have no
path). A separate host name also keeps Pocket ID's cookies apart from the Console's
`__Host-mosaic_*` cookies; the two share cookies if they sit on one host name on different ports.
- The front reaches Pocket ID on loopback, so the front has to run on the same host. If it doesn't, Pocket
ID would need a non-loopback bind, and that is a `network.expose` hard wait (REQ-WAIT-1), not a component
option.
- `TRUST_PROXY` is set to the front's address only, never `true`.
- The Console fetches discovery and JWKS from `issuer` over https from the server side, so the Console's
node has to trust the front's certificate. A public CA does that. A private CA needs
`NODE_EXTRA_CA_CERTS` in the Console unit, which the build row adds as a render flag.
These steps are the user's, and on Jason's hosts they are Jason's, because each one changes who can reach
the Console or Pocket ID, or who the host trusts:
- J1: the DNS name for `origin`;
- J2: the certificate for it, or a private CA and its trust in the browser and in `NODE_EXTRA_CA_CERTS`;
- J3: the TLS front's route from `origin` to `127.0.0.1:<port>` (a Traefik change on Jason's hosts);
- J4: any listener off loopback.
The component checks and refuses; it never does them itself. With none of them done, Pocket ID runs on
loopback with an https `APP_URL` that nothing serves yet. Enroll's step 2 health probe goes straight to
`127.0.0.1`, so enroll still works. The person can't add a passkey until J1 to J3 are done.
## 7. Agents
Unchanged. Agents keep their per-role service tokens (lead decision 44 item 6, REQ-INST-2). Pocket ID issues
nothing an agent uses. The Console client is the only client enroll creates. No API, no client-credentials
grant and no admin API key exist after enroll. Residuals, stated plainly:
- Agents can reach `127.0.0.1:<port>`, because the sandbox shares the host network. What they can reach there
is the public sign-in pages, discovery and JWKS. Signup is disabled, email codes are off, `/setup` is
closed because an admin exists, and there is no static key. A test of the rendered unit pins all four
settings.
- The data directory and the two slots are outside every sandbox bind (section 2).
- A seat that ran as the same OS user outside a sandbox could read the data directory. The Console's
existing slot has the same exposure; this design adds no new class of it.
## 8. Instances
One Pocket ID per named instance, never shared:
- Each instance has one system config (row 190), and `components` and `console.sso` live in it. A shared
Pocket ID would make one instance's config depend on a unit, a data directory and an encryption key that
belong to another instance's config and data root.
- Unit names, ports, data directory, slots and removal are all per instance (`mosaic-pocket-id-NAME`,
`<dataRoot of NAME>/components/pocket-id`), so removing one instance's component can't break another's
sign-in.
- Each instance needs its own `origin` (section 6), so passkeys are per instance too. A person enrolls once
per instance.
- An instance that wants to use another instance's Pocket ID can still do it with `--oidc existing`. Then it
is just an OIDC provider, with its own client registered by that instance's owner. Nothing special-cases it.
Two instances on one host each need their own `port`. The loader can't see other instances' configs, so
`component-pocket-id.sh` refuses at fetch and enroll when the port is already bound on loopback.
## 9. Tests, stand-ins only
No test downloads or starts a real Pocket ID, and no test touches auth.diversecanvas.com,
mosaic.woltje.com or 127.0.0.1:7332. The stand-ins:
- **Fake Pocket ID OP**, a node server like row 192's fake OP, shaped from v2.19.1:
- the discovery fields `well_known_controller.go` emits (issuer = origin, `plain` and `S256`,
`end_session_endpoint` at `/api/oidc/end-session`, the three auth methods);
- one RS256 JWK at `/.well-known/jwks.json`;
- a token endpoint that takes only `client_secret_basic`;
- `/healthz` 204.
The Console starts against it under `--sso` with the section 5 entry, with no check changed. Negative
cases:
- an http issuer refuses at load;
- an ES256-only JWKS against `algs: ["RS256"]` refuses at discovery;
- an issuer with a trailing slash refuses on the cross-check.
- **Fake admin API** on the same stand-in: `POST /api/users`, the client create, the one-time token, and a
static-key check. Enroll runs against it with the binary replaced by a stub script that serves the stand-in.
The tests pin:
- the namespace argv;
- the slot file's mode and `O_EXCL`;
- that the secret appears in no stdout, stderr or argv;
- the static key file gone after success and after a failure at each step;
- the refusal under an agent marker in the environment.
- **Fetch**: a local file served by a test https server (self-signed, trusted only by the test) with a
matching pin, a mismatched pin and a truncated file.
- **Unit render**: the environment table in section 2. `HOST=127.0.0.1`, no `STATIC_API_KEY`, no
`ENCRYPTION_KEY` value in the text, and `ALLOW_USER_SIGNUPS=disabled`.
- **Loader**: test-config.sh cases for the `components` key and the cross-check, and the copy-rollback note.
- **Sandbox**: `<dataRoot>/components/` is in no bind list.
## 10. Build rows, in landing order
All are post-v1 (REQ-INST-2). Each row has Webby or Sage's named reviewer and its own gate. None of them
runs on .53, .54 or dragon-lin, and none installs Pocket ID on any host.
1. **P1, loader key.** `components["pocket-id"]` in `mosaic-config.mjs`, strict inside the key, and the
cross-check against `console.sso` (section 1, M1 conditions from mos-1666 item 3). P1 also writes the
rollback order into `docs/TOOLS.md` beside Q175's: remove `components` (restore the backup) before the
pointer goes back, and the post-rollback STATE check confirms the key is absent from config.json. Gate:
test-config.sh green with the four M1 cases.
2. **P2, pin and fetch.** `scripts/pocket-id-pins.sh` with the build-time sha256 and the
attestation evidence, and `component-pocket-id.sh fetch|verify`. Gate: the fetch tests against the local
stand-in; the evidence file shows `gh attestation verify` output for each pinned asset.
3. **P3, unit.** The unit template, `render|install|status|remove|purge`, and the encryption key slot. Gate:
the render and remove tests, and the sandbox bind test.
4. **P4, enroll.** The namespaced bootstrap, the client, the subject, `relink`, and printing the entry (R1),
(R2 rejected). Gate: the enroll tests in section 9, and a secret-leak grep over every test log.
5. **P5, Console against the fake Pocket ID.** The section 9 fake-OP start tests, and the
`NODE_EXTRA_CA_CERTS` render flag for the Console unit. Gate: the webui suite, with no change to
`sso.mjs` checks (a diff of `sso.mjs` and `sso-config.mjs` against the base shows none).
6. **P6, installer option.** `--oidc existing|bundled` on REQ-INST-3's install command, calling P2 to P4 in
order, and the docs: `docs/TOOLS.md`, the host run sheet's J1 to J4 steps. Gate: the installer's own
suite against the stubs; the docs list J1 to J4 as the user's steps.
## Open items for review
- M1 approved by Mos with three conditions (mos-1666 item 3), written into section 1 and P1.
- M2 dropped: Sage chose R1 and rejected R2 (sage-4401).
- **Q1 (Webby):** the private network namespace for enroll (section 4). The other option is to start the
real unit and race `/setup`, which I think is unsafe while agents share the host network.
- **Q2 (Webby):** one-providers-entry for the bundled choice (section 1). REQ-INST-2's wording reads that
way. Mixing a bundled Pocket ID with existing providers would need Sage's ruling.
- Not covered: Pocket ID for Vikunja or Gitea people sign-in (the Researcher note's part 3), and
client-credentials between Mosaic services. Neither is in Q203.
+187
View File
@@ -0,0 +1,187 @@
# Row 267 (C2-15, #1772): hybrid universes, exclusions, pins and the crossover. Build notes
Morgana, 2026-10-25. Brief: `docs/plans/2026-10-20_case2-rows.md` "C2-15 hybrid universes: exclusions,
pins and the end-to-end crossover" (blob 85e6fe2e). Reviewer Filbert, gate owner Sage. Claimed at rev 4087.
Branch `morgana/row267` from refactor 78b69e1e6. Design 1.4 is `agents/gosalyn/work/case2/DESIGN.md`.
Status: build committed under sage-4389 and sage-4407. Mos approved the survey.test.mjs:75 change (mos-1667,
read against 5b2da2203). Nothing is run. No runs before Sage's CLEAR.
## 1. What the code does today (78b69e1e6)
- `mapping.mjs:119-134` checks only the shape of a constraint. `exclude` is any folder path, and
`pin.entity` is any folder path with a scalar value. Nothing checks either against the sources.
- `query.mjs:52-57` `sees()` reads `exclude` as a path prefix only (`inside(c.exclude, file.path)`). The
type form `second/Characters` (`mapping.test.mjs:203`) passes the validator but hides nothing, because
no file path starts with `second/`.
- `reader.mjs:206-210` gives R6 only for a canon link into a universe that is neither the file's own nor
one of its sources. A hybrid canon file that links into an excluded source path gets no problem.
- `guard.mjs:91-97` lets any mix of one hybrid and its sources pass. It doesn't check exclusions.
- `problems.mjs` has R1-R7 and no R8.
## 2. Design
### 2.1 Validator (`mapping.mjs`)
The validator runs after every universe is read, alongside the existing sources check at `:141-146`,
because it needs each source's books, playground and supporting lists. For each constraint of a hybrid:
- **Type form:** the first segment is one of the hybrid's `sources`, and the rest is exactly one key of
`types`.
- **Path form:** the path equals, or lies inside, a book folder of one of the sources.
- Both forms fit: refuse `library-mapping-invalid` ("... reads as both a source/type and a source path").
- Neither form fits: refuse `library-mapping-invalid` ("... lies inside no book of the hybrid's
sources"). This covers a path in the hybrid's own books, in a non-source universe, or unmapped.
- **Path form under a playground:** a path equal to or inside one of a source's playground prefixes
refuses ("... lies under a playground of universes.X, which the hybrid never reads"). "Under" means the
path equals the prefix without its trailing `/`, or starts with the prefix.
- **`pin.entity`:** the same path-form rules apply, with no type form. An entity is one file.
The stored shape stays `{ exclude }` / `{ pin }`. I don't add a `form` field. `library_universe`
returns `constraints` as they are now, and the index cache digest doesn't change for existing mappings.
`excluded()` works out the form again each time. It's pure and cheap.
### 2.2 `excluded(mapping, hybrid, path)` (`scope.mjs`)
This returns true when any `exclude` of universe `hybrid` covers `path`:
- **Type form `S/T`:** `path` starts with `${book}/${T}/` for some book of source S.
- **Path form P:** `path === P` or `path` starts with `${P}/`.
It returns false when `hybrid` isn't a hybrid. It reads only the path (D2). It has three callers:
- `query.mjs` `sees()` replaces its own `inside(c.exclude, ...)` reading with this helper.
- `reader.mjs` uses it for the excluded-link R6.
- `guard.mjs` uses it for the excluded cite.
`query-fixture.mjs` `EXCLUSION` (`Book One/Research`) is path form and still fits, so the existing
C2-05a query tests keep their constants.
### 2.3 Reader: excluded-link R6 and R8
- **Excluded-link R6:** in the canon branch at `:206`, a hybrid file's link to a target where
`excluded(mapping, file.universe, to.path)` is true gives `problem('R6', file.path, link.line)`, with
the default message. A custom message isn't possible: `mirror.mjs:196-201` `validProblem` accepts a
sync-state problem only when `message === RULES[code]`, so a new message would make every later sync
state read refuse `library-state-invalid`. The link stays in `file.links`, as the other R-rules leave
it. `sees()` already drops it from the hybrid's `links_out`. A link that is both foreign and excluded
can't occur, because an excluded path always lies in a source, so each link gets at most one R6.
- **R8:** a new `RULES.R8` entry reads "pinned value changed". The check runs once per commit, after the
link pass, for each pin of each hybrid:
- **Entity missing** from `byPath`: R8 at `entity`, with no line. This covers a file that is absent,
R1, R2 or R7.
- **Entity present:** R8 at `entity`, on the `field` key's line where there is one, unless the parsed
front matter holds `field` as a scalar (string, number or boolean) and `String(parsed) ===
String(value)`. A list, null, a missing key and unparsed (R3) front matter all count as changed. Under
a plain `String()` comparison, `['grey']` would equal `'grey'`, so this rule closes that hole.
- **Hybrid versions:** each canon file of the hybrid whose `type` (from `classify`) equals the entity's
type, and whose name matches the entity's, is a version of it. Names are the trimmed front matter
`name` on both sides. When either file has no text `name`, the file names (stems) compare instead.
A version that holds `field` with another value gets R8 at its own path, on that key's line. A version
without `field` gets nothing, and neither does any other file that only links to the entity.
- The entity's type and name come from `classify(mapping, entity)` and the entity's parsed file. When the
entity is missing, its stem stands in for the name.
- An entity outside any type folder (a book root file, a manuscript file, or a folder that isn't a type)
has type null or `manuscript`. Its versions are then the hybrid canon files with that same type and a
matching name or stem. The brief doesn't cover this case, and no special rule is added for it (Filbert N2).
- R8 entries are deduplicated by path and line, so two pins or two hybrids on one missing or changed
entity give one entry (Filbert N1). Survey counts and the sync state count each path and line once.
### 2.4 Guard (`guard.mjs`)
After the existing span check at `:91-97`, the guard looks for a hybrid among the cited universes. If
there is one, and some cite lies in one of its sources while `excluded(mapping, hybrid, c.path)` is true,
the guard refuses `citation-cross-universe` ("citation N (path) is excluded from hybrid H"). A reply that
cites only source paths, excluded ones included, has no hybrid among its universes, so it's unchanged.
### 2.5 Setup sheet
`docs/case2/SETUP-SHEET.md` gets one section after "Supporting folders", headed "Making a crossover
(hybrid) universe". It covers `kind`, `name`, `sources`, `books`, `playground`, `constraints`
(`exclude` in both forms, `pin`) and `notes`, and says that the continuity role reads and cites the notes.
It also says that Jason edits this entry by hand and agents can't change it (C2-04 rule M9), as the
sheet already says for the rest of the entry.
## 3. Tests and mutants
New files and cases, all on scratch repos under TMPDIR (never /tmp):
- **`packages/library/tests/hybrid.test.mjs` (new):**
- A business-file-shaped mapping (fixture `mapping.json` plus a `cross` hybrid) on a repo in her
layout's shape.
- `find` and `search` in `cross` return its own files plus the sources' canon, each carrying its source
universe. They never return a source playground or an excluded path, and this runs once for each
exclude form.
- `get` keeps `links_out` into the sources and drops excluded targets.
- A source universe's lookups never return a `cross` file.
- `universeInfo` returns the sources, constraints and notes.
- `excluded()` gets unit cases for both forms and for a non-hybrid.
- **`mapping.test.mjs`:** one case per new refusal: both forms fit, neither fits, the hybrid's own book,
an exclude under a source playground, a pin under a source playground, and a `pin.entity` that doesn't
end in `.md` (Q5). `second/Characters` at `:203` still checks.
- **`reader.test.mjs`:**
- R8 cases: entity missing, two pins on a missing entity give one entry (N1), entity changed, a same-name hybrid version with no link, and a list value.
- Non-R8 cases: a hybrid place file with `age: 300` that links the entity, and a different hybrid
character that links it.
- The excluded-link R6, once for each exclude form.
- **`guard.test.mjs`:** a hybrid cite with source canon passes, and a hybrid cite beside an excluded path
refuses in both forms. A cite of only an excluded source path passes as that universe's item.
- **`packages/bus/tests/citation-guard.test.mjs`:** through `citationWorld` with `mapChange` adding the
hybrid, the excluded-beside-hybrid send refuses and the `messages` count stays the same.
- **Sync state after an excluded-link R6 (sage-4389 finding a):** one case that syncs a commit holding a
hybrid file's excluded link, then reads the sync state back with `readSyncState` and finds the R6 with
the default message. This shows `validProblem` (`mirror.mjs:196-201`) still accepts the state.
- **`packages/bus/tests/library-lookups.test.mjs`:** one broker case, where `library.find` and
`library.universe` in the hybrid return what the library tests above assert.
- **C2-04 M9 rule (agents can't write `projects`), not a mutant:** a case in `packages/business/tests/library-projects-protected.test.mjs` where an organization
write that adds a new hybrid universe refuses `organization-projects-protected`. This is outside the
brief's "tests in library and bus" list. Sage ruled it in (Q1), so the business suite is on the run list.
- **`docs/case2/SURVEY-RUN.md:98`** names the problems line as `R1 .., ..., R8 ..`. No test reads that file.
- **One existing assertion changes:** `survey.test.mjs:75` gains `, R8 0`. The before and after lines are
in `assertion-diff.md`, which goes to Mos before the commit (mos-1272).
Mutants. Each runs only its own named tests, and the tree is restored after each one.
| # | Mutation | Killed by |
|---|---|---|
| X1 | `excluded()` ignores the type form | hybrid.test "C2-15 excluded(): ..." and "C2-15 type form: find and search ..." |
| X2 | `excluded()` ignores the path form | hybrid.test "C2-15 excluded(): ..." and "C2-15 path form: find and search ..." |
| X3 | `sees()` keeps its old `inside()` reading | hybrid.test "C2-15 type form: find and search ..." |
| X4 | the validator accepts "both forms fit" | mapping.test "C2-15: each exclude and pin is checked against the hybrid's sources" |
| X5 | the validator skips the playground check | mapping.test "C2-15: each exclude and pin is checked against the hybrid's sources" |
| X6 | R8 compares with plain `String()` (list equals scalar) | reader.test "C2-15 R8: the pinned entity reads clean ..." (list case) |
| X7 | a version is matched by link instead of name | reader.test "C2-15 R8: a hybrid version of the entity ..." |
| X8 | the reader drops the excluded-link R6 | reader.test "C2-15 R6: a hybrid file that links into an excluded source path ..."; sync.test "C2-15 sage-4389 (a) ..." also fails |
| X9 | the guard skips the excluded check | guard.test "C2-15 a hybrid cite beside an excluded source path refuses citation-cross-universe, both forms" (the mutant run); bus citation-guard "C2-15 excluded cite beside a hybrid refuses and inserts nothing" also fails |
## 4. Rulings (sage-4389)
- **Q1 yes.** The C2-04 M9 case is new and goes only in `packages/business/tests/library-projects-protected.test.mjs`.
No existing assertion there changes. The business suite is on the run list.
- **Q2 dropped.** The condition was a file:line showing that the hybrid's index, reader and guard never
read a source's supporting prefix. Two of the three do read it:
- Index: `query.mjs:55` `sees()` returns false for a source file whose scope isn't `canon`, so the
hybrid's lookups never show a source's supporting file. This one holds.
- Reader: `reader.mjs:206-209` checks R5 for playground targets only, and R6 skips any target in a
source universe. A hybrid canon file that links into a source's supporting file gets no problem and
keeps the link (`:211`).
- Guard: `guard.mjs:91-97` checks the cited universes and never their scope. A reply that cites a
hybrid file beside a source's supporting file passes, and so does one beside a source's playground
file (`guard.mjs:87-90` only stops a playground cite marked `as: 'canon'`).
Sage's ranges in sage-4399 (`reader.mjs:206-211`, `guard.mjs:87-97`) cover the same lines. Sage files
a DEFERRED entry for the guard and reader gap at landing.
So the validator does not refuse an exclude under a source's supporting prefix. The guard observation
is outside this row's guard scope ("the excluded-path cite only"); I report it to Sage as a possible
follow-up and don't change it here.
- **Q3:** R8 reaches the survey and the sync state as described. `survey.test.mjs:75` goes through
`assertion-diff.md`. Finding (a) adds the sync-state readback test in §3. `packages/library/README.md`
lists R1-R7 in its problems table, so it gains an R8 row; no test reads that file.
- **Q4 as written:** versions count only in the hybrid's canon.
- **Q5 yes:** a `pin.entity` that doesn't end in `.md` refuses `library-mapping-invalid`, with its own test.
- **Finding (a):** R6 keeps the default message, with the readback test above.
## 5. Gate
Filbert approves the manifest. The library and bus suites, plus business if Q1 stands, run green on
Sage's gate along with every `scripts/test-*.sh`. Hash pins on the changed files are grepped before
in-review.
@@ -0,0 +1,22 @@
# Row 267: one existing assertion changes (mos-1272)
File: `packages/library/tests/survey.test.mjs:75`, in the test that checks the survey's last three lines
with `--mapping`.
Before:
```
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
```
After:
```
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0, R8 0']);
```
Why: R8 ("pinned value changed") is a new rule in `problems.mjs` `RULES`, and the survey line lists a
count for every `RULES` key (`survey.mjs:70`), so it gains `R8 0`. The fixture has no hybrid and no pin,
so the count is 0 and no other count moves.
No other existing assertion in the row changes.
+31
View File
@@ -65,6 +65,37 @@ Tell Jason which folders you want to mark. He adds them to the `supporting` list
entry of your business file. He edits that entry by hand, and you can ask him to add or change a folder
later.
## Making a crossover (hybrid) universe
A crossover is its own universe, called a hybrid. It reads the canon of the universes it draws on, its
sources, and has its own books. Agents only cross worlds inside one. Like the rest of the `projects`
entry, Jason adds it by hand and the agents can't change it. Tell him what you want, and he writes an
entry with these keys:
- `kind`: `"hybrid"`.
- `name`: the name you call it by.
- `sources`: two or more universes it reads.
- `books`: its own book folders. It may start with none.
- `playground` and `supporting`: its own folders, the same as for any universe.
- `constraints`: the rules below, if you want any.
- `notes`: text on how the worlds fit together, up to 4000 characters. The continuity agent reads the
notes and cites them.
A hybrid never reads a source's playground.
There are two kinds of constraint:
- `exclude` hides part of a source. It takes two forms. A source and a type folder, for example
`second/Characters`, hides that type folder in each of that source's books. A path, for example
`Book One/Research`, hides that folder or note, which must sit in a book of one of the sources. Agents
in the hybrid don't see what is hidden, can't cite it beside a hybrid note, and a hybrid note that links
to it gets a link warning.
- `pin` holds one fact fixed: a source note (`entity`, a `.md` path), a property (`field`) and its value
(`value`). If the note goes missing or the property changes, Mosaic warns. It also warns when a hybrid
note of the same type and name sets that property to something else. Linking to the note doesn't count:
a hybrid character who links `[[Mara]]` is someone else.
Mosaic refuses an entry whose `exclude` or `pin` points outside the sources' books, or into a source's
playground.
## Saves and agent notes
When you save a file in the Console, Mosaic makes a commit under the name and email you give Jason for
+1 -1
View File
@@ -95,7 +95,7 @@ With `--mapping` three lines follow:
|---|---|
| `scopes canon .., playground .., unmapped ..; indexed ..` | tree entries per scope, and notes indexed |
| `resolved root .., vault-prefix .., relative .., shortest .., unresolved ..` | links per resolver step. `unresolved` is the R4 count |
| `problems R1 .., ..., R7 ..` | problems per rule |
| `problems R1 .., ..., R8 ..` | problems per rule |
Folder paths and short property names are the only words from her repo in the output. In a folder
name, tab, newline, carriage return, backspace and form feed print as `\t`, `\n`, `\r`, `\b` and `\f`.
+26 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Client } from '../src/client.mjs';
import { serve } from '../src/server.mjs';
@@ -68,6 +68,31 @@ test('C2-06 supporting file stores supporting scope and its real agent trailer',
assert.deepEqual(JSON.parse(event.body).check, { citations: [{ scope: 'supporting', agent: 'acme/scribe', file }] });
});
// C2-15: a hybrid over harbor and second that excludes harbor's Places folder (path form).
const crossMapping = entry => {
entry.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'],
constraints: [{ exclude: 'Book One/Places' }] };
return entry;
};
test('C2-15 excluded cite beside a hybrid refuses and inserts nothing', async t => {
const f = citationWorld(t, { mapChange: crossMapping }); f.call(f.agent, 'role.claim');
mkdirSync(join(f.repo, 'Crossover/Chapters'), { recursive: true });
writeFileSync(join(f.repo, 'Crossover/Chapters/01.md'), 'A hybrid fixture.\n');
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'hybrid fixture');
const commit = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(commit);
const hybrid = fixtureCite(commit, { path: 'Crossover/Chapters/01.md', lines: [1, 1], quote: 'hybrid fixture' });
const hidden = fixtureCite(commit, { path: 'Book One/Places/Harbor Town.md', lines: [4, 4], quote: 'Salt.' });
const count = f.store.get('SELECT count(*) n FROM messages').n;
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: citedBody([hybrid, hidden]) }),
e => e.code === 'citation-cross-universe' && e.reason === 'citation 2 (Book One/Places/Harbor Town.md) is excluded from hybrid cross');
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, count);
assert.equal(f.store.all("SELECT * FROM events WHERE json_extract(body,'$.action')='message.checked'").length, 0);
// The hybrid beside source canon it can see goes through.
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([hybrid, fixtureCite(commit)]) });
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, count + 1);
});
test('C2-06 check evidence and insert are atomic when storing evidence fails', async t => {
const f = citationWorld(t); f.call(f.agent, 'role.claim');
f.store.run(`CREATE TRIGGER fixture_check_refused BEFORE INSERT ON events
@@ -251,3 +251,18 @@ test('C2-05c broker forwards supporting scope and returns the served blob withou
assert.equal(await f.call('library.get', { universe: 'second', path, scope: 'supporting' }), null);
await assert.rejects(f.call('library.manuscript', { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
});
test('C2-15 broker: library.find and library.universe in a hybrid see its own files and source canon, minus the type-form exclude', async t => {
const projects = { books: mappingEntry() };
projects.books.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'],
constraints: [{ exclude: 'second/Characters' }], notes: 'Fixture crossover.' };
const f = await world(t, { projects, files: { ...TEXTS, 'Crossover/Chapters/01.md': '# Both\n[[Tomas]] and [[Elsewhere]].\n' } });
const found = await f.call('library.find', { universe: 'cross', query: '' });
assert.deepEqual(found.results.map(x => [x.path, x.universe]), [
['Book One/Chapters/01.md', 'harbor'], ['Book One/Characters/Tomas.md', 'harbor'], ['Crossover/Chapters/01.md', 'cross']]);
assert.deepEqual((await f.call('library.find', { universe: 'cross', query: '', scope: 'playground' })).results, []);
assert.ok(!(await f.call('library.find', { universe: 'harbor', query: '' })).results.some(x => x.path.startsWith('Crossover/')));
const universe = await f.call('library.universe', { universe: 'cross' });
assert.deepEqual([universe.kind, universe.sources, universe.constraints, universe.notes],
['hybrid', ['harbor', 'second'], [{ exclude: 'second/Characters' }], 'Fixture crossover.']);
});
@@ -67,3 +67,12 @@ test('C2-04 M9: a serialization hook cannot change projects after the canonical
Object.defineProperty(next.projects, 'toJSON', { value: () => changed });
});
});
test('C2-15: an organization write that adds a hybrid universe with exclude and pin constraints is protected', t => {
const f = fixture(t);
protectedWrite(f, next => {
next.projects.books.universes.cross = { kind: 'hybrid', name: 'An invented crossover', sources: ['harbor', 'second'],
books: ['Crossover'], playground: [], notes: 'Fixture only',
constraints: [{ exclude: 'second/Characters' }, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'name', value: 'Mara Vell' } }] };
});
});
+2 -1
View File
@@ -119,8 +119,9 @@ Problems carry `{ code, path, line, message }`:
| R3 | front matter not fully parsed, `line` is where parsing stopped | yes, front matter kept raw |
| R4 | link resolves to no file or to more than one | yes, link dropped |
| R5 | canon file links into the playground | yes |
| R6 | canon file links into another universe, a hybrid's sources excepted | yes |
| R6 | canon file links into another universe, a hybrid's sources excepted, or a hybrid's file links into a path its `exclude` covers | yes |
| R7 | two paths differ only in case | no, neither |
| R8 | pinned value changed: a hybrid's pinned entity is missing or its `field` differs from `value`, or a hybrid version of it sets `field` to another value | yes |
The front matter parser takes scalars, quoted strings, flow lists, block lists and wikilinks. Nested
maps, anchors, block scalars and duplicate keys stop it with R3.
+6 -1
View File
@@ -3,7 +3,7 @@
// only the citations, never what the text says. Codes are design 5.2's table.
import { lstatSync } from 'node:fs';
import { git } from '../../project/src/git.mjs';
import { classify } from './scope.mjs';
import { classify, excluded } from './scope.mjs';
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
import { LibraryError } from './problems.mjs';
import { mirrorPath, readSyncState } from './mirror.mjs';
@@ -94,6 +94,11 @@ export async function checkReply(body, { limits, mapping, library, web }) {
const sources = hybrid ? mapping.universes[hybrid].sources : [];
if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u)))
refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`);
// C2-15: beside a hybrid cite, a source path the hybrid excludes is outside the hybrid's world.
citations.forEach((c, i) => {
if (found[i] && found[i].universe !== hybrid && excluded(mapping, hybrid, c.path))
refuse('citation-cross-universe', `citation ${i + 1} (${c.path}) is excluded from hybrid ${hybrid}`);
});
}
citations.forEach((c, i) => {
if (!Object.hasOwn(c, 'url')) return;
+1 -1
View File
@@ -1,7 +1,7 @@
// packages/library: her writing repo read by a mapping (Case 2, design 1 and 7.4). See README.md.
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
export { checkMapping, TYPES } from './mapping.mjs';
export { classify } from './scope.mjs';
export { classify, excluded } from './scope.mjs';
export { supportingPath } from './supporting.mjs';
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
+27
View File
@@ -5,6 +5,7 @@
// Business file loading wires it in C2-04, which passes its own `fail` so the refusal reads as a
// business file problem. Agents can't write this entry (M9, C2-04).
import { raise } from './problems.mjs';
import { typeForm } from './scope.mjs';
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -144,6 +145,32 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a plain universe`);
}
}
// C2-15 (design 1.4): each constraint against the hybrid's sources, now that every source is read. An
// exclude reads as the type form `source/Type` or as a path in a source's book, never both. A path, or
// a pinned entity, under a source's playground is one the hybrid never reads (REQ-LIB-3). Whether the
// path exists at a commit is the reader's to check.
for (const [id, universe] of Object.entries(universes)) {
if (universe.kind !== 'hybrid') continue;
const sources = universe.sources.map(source => [source, universes[source]]);
const inSourceBook = path => sources.some(([, s]) => s.books.some(book => path === book || holds(book, path)));
const underPlayground = path => sources.find(([, s]) => s.playground.some(p => path === p.slice(0, -1) || path.startsWith(p)))?.[0];
const sourcePath = (path, at) => {
if (!inSourceBook(path)) bad(`${at} ${JSON.stringify(path)} lies inside no book of the hybrid's sources`);
const owner = underPlayground(path);
if (owner) bad(`${at} ${JSON.stringify(path)} lies under a playground of universes.${owner}, which the hybrid never reads`);
};
universe.constraints.forEach((constraint, i) => {
const at = `universes.${id}.constraints[${i}]`;
if (constraint.pin) {
if (!constraint.pin.entity.endsWith('.md')) bad(`${at}.pin.entity ${JSON.stringify(constraint.pin.entity)} is not a .md file`);
return sourcePath(constraint.pin.entity, `${at}.pin.entity`);
}
const type = typeForm(entry, universe, constraint.exclude);
if (type && inSourceBook(constraint.exclude))
bad(`${at}.exclude ${JSON.stringify(constraint.exclude)} reads as both a source/type and a source path`);
if (!type) sourcePath(constraint.exclude, `${at}.exclude`);
});
}
const books = [...bookOwner.keys()];
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
+1
View File
@@ -13,6 +13,7 @@ export const RULES = Object.freeze({
R5: 'canon file refers into the playground',
R6: 'canon file refers into another universe',
R7: 'paths differ only in case',
R8: 'pinned value changed',
});
export function problem(code, path, line, message = RULES[code]) {
+4 -4
View File
@@ -8,6 +8,7 @@ import { raise } from './problems.mjs';
import { TYPES } from './mapping.mjs';
import { indexFor } from './index-build.mjs';
import { listTree } from './reader.mjs';
import { excluded } from './scope.mjs';
// The same cap as packages/harness/src/tools.mjs REPLY_CAP. The query tests assert the two stay equal.
export const QUERY_REPLY_CAP = 40_000;
@@ -19,7 +20,6 @@ const SCOPE_RANK = Object.freeze({ supporting: 0, playground: 1, canon: 2 });
const bytes = value => JSON.stringify(value).length;
const invalid = message => raise('query-argument-invalid', message);
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
function textArg(value, name) {
if (typeof value !== 'string') invalid(`${name} must be text`);
@@ -47,13 +47,13 @@ function universeArg(mapping, value) {
}
// Whether universe U may see a file. Its own files, in either scope. A hybrid also sees the canon of
// its declared sources, minus the folders its exclude constraints name. A hybrid never sees a source's
// playground. Pin constraints do not hide anything; they are reported by universeInfo only.
// its declared sources, minus what its exclude constraints cover (scope.mjs excluded(), C2-15). A hybrid
// never sees a source's playground. Pin constraints hide nothing; the reader reports them as R8.
export function sees(mapping, universe, file) {
if (file.universe === universe) return true;
const entry = mapping.universes[universe];
if (entry.kind !== 'hybrid' || !entry.sources.includes(file.universe) || file.scope !== 'canon') return false;
return !entry.constraints.some(c => c.exclude !== undefined && inside(c.exclude, file.path));
return !excluded(mapping, universe, file.path);
}
// The files one universe may see in one scope, with optional book and type filters, in path order.
+50 -3
View File
@@ -4,7 +4,7 @@
// way. Nothing in the repo is checked out or run (D7). Each R-rule is a problem, never a refusal
// of the commit; the affected file or link is left out of the index.
import { git, gitBytes } from '../../project/src/git.mjs';
import { classify } from './scope.mjs';
import { classify, excluded } from './scope.mjs';
import { extractLinks, isAttachment, parseWikilink, resolver } from './links.mjs';
import { LibraryError, problem, raise, sortProblems } from './problems.mjs';
@@ -169,7 +169,7 @@ function frontMatterLine(lines, bodyLine, field) {
}
// Read a commit under a checked mapping (mapping.mjs). Returns the indexed files and every
// R1-R7 problem: { commit, files: [...], problems: [...] }, frozen, sorted by path.
// R1-R8 problem: { commit, files: [...], problems: [...] }, frozen, sorted by path.
export async function readLibrary({ repo, commit, mapping }) {
if (!mapping || mapping.kind !== 'library') throw new LibraryError('library-mapping-invalid', 'expected a checked library mapping');
const entries = await listTree(repo, commit);
@@ -206,17 +206,64 @@ export async function readLibrary({ repo, commit, mapping }) {
if (file.scope === 'canon') {
if (to.scope === 'playground') problems.push(problem('R5', file.path, link.line));
const sources = mapping.universes[file.universe].sources ?? [];
if (to.universe !== file.universe && !sources.includes(to.universe)) problems.push(problem('R6', file.path, link.line));
// A hybrid's link into a path its exclude covers is R6 too (C2-15). An excluded path is always in a
// source, so a link gets at most one R6.
if ((to.universe !== file.universe && !sources.includes(to.universe)) || excluded(mapping, file.universe, to.path))
problems.push(problem('R6', file.path, link.line));
}
kept.push({ ...link, path: target.path, step: target.step });
}
file.links = kept;
delete file.stop;
}
problems.push(...pinProblems(mapping, files, byPath));
files.sort((a, b) => a.path < b.path ? -1 : 1);
return deepFreeze({ commit, files, problems: sortProblems(problems) });
}
// R8 (C2-15, design 1.4): each pin of each hybrid. The pinned entity gets R8 when it isn't indexed (absent,
// R1, R2 or R7) or its front matter doesn't hold `field` as a scalar whose text equals String(value); a
// list, null, a missing key and R3 front matter all count as changed. A hybrid canon file is a version of
// the entity when classify gives it the entity's type and its name matches: the trimmed front matter
// `name` on both sides, or the file names when either has no text name. A version that sets `field` to
// another value gets R8 on that key's line. A link alone never makes a version.
function pinProblems(mapping, files, byPath) {
const out = [];
for (const [hybrid, entry] of Object.entries(mapping.universes)) {
if (entry.kind !== 'hybrid') continue;
for (const { pin } of entry.constraints.filter(c => c.pin)) {
const entity = byPath.get(pin.entity);
if (!entity) out.push(problem('R8', pin.entity));
else if (!pinned(entity, pin)) out.push(problem('R8', pin.entity, keyLine(entity, pin.field)));
const type = classify(mapping, pin.entity).type;
const stem = pin.entity.split('/').pop().replace(/\.md$/, '');
const name = entity ? textName(entity) : null;
for (const file of files) {
if (file.universe !== hybrid || file.scope !== 'canon' || file.type !== type) continue;
const own = textName(file);
if ((name !== null && own !== null ? own !== name : file.name !== stem)) continue;
if (file.frontmatter && Object.hasOwn(file.frontmatter, pin.field) && !pinned(file, pin))
out.push(problem('R8', file.path, keyLine(file, pin.field)));
}
}
}
// Two pins (or two hybrids) on one missing or changed entity give one entry per path and line (Filbert N1).
const seen = new Set();
return out.filter(p => { const key = `${p.path}\0${p.line ?? ''}`; return !seen.has(key) && seen.add(key); });
}
const textName = file => typeof file.frontmatter?.name === 'string' && file.frontmatter.name.trim() ? file.frontmatter.name.trim() : null;
function pinned(file, { field, value }) {
const held = file.frontmatter && Object.hasOwn(file.frontmatter, field) ? file.frontmatter[field] : undefined;
return ['string', 'number', 'boolean'].includes(typeof held) && String(held) === String(value);
}
// The front matter line of `field` (bare or quoted key), or undefined.
function keyLine(file, field) {
if (file.bodyLine < 3) return undefined;
const key = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const at = file.lines.slice(1, file.bodyLine - 2).find(([, t]) => new RegExp(`^["']?${key}["']?\\s*:`).test(t));
return at?.[0];
}
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const v of Object.values(value)) deepFreeze(v); }
return value;
+23
View File
@@ -36,3 +36,26 @@ function place(mapping, path, under, where) {
: mapping.manuscript.includes(folder) ? 'manuscript' : null;
return { ...where, type, folder };
}
// C2-15 (design 1.4): whether an `exclude` constraint of universe `hybrid` covers `path`. The type form
// `source/Type` (first segment one of the hybrid's sources, the rest one key of `types`) covers that type
// folder in each of the source's books. Otherwise the exclude is a path and covers itself and what lies
// inside it. checkMapping keeps the two readings from both fitting. False for a universe that isn't a
// hybrid. Reads only the path (D2).
export function excluded(mapping, hybrid, path) {
const entry = mapping.universes[hybrid];
if (entry?.kind !== 'hybrid' || typeof path !== 'string') return false;
return entry.constraints.some(c => {
if (c.exclude === undefined) return false;
const type = typeForm(mapping, entry, c.exclude);
if (type) return mapping.universes[type.source].books.some(book => path.startsWith(`${book}/${type.folder}/`));
return path === c.exclude || path.startsWith(`${c.exclude}/`);
});
}
// { source, folder } when `exclude` reads as the type form for this hybrid entry, else null.
export function typeForm(mapping, entry, exclude) {
const [source, ...rest] = exclude.split('/');
const folder = rest.join('/');
return entry.sources.includes(source) && rest.length === 1 && Object.hasOwn(mapping.types, folder) ? { source, folder } : null;
}
+19
View File
@@ -73,6 +73,25 @@ test('C2-06 independent universes refuse; a hybrid and only its sources pass', a
assert.equal((await checkReply(body([hybrid, cite(r.commit), other]), c)).citations.length, 3);
});
test('C2-15 a hybrid cite beside an excluded source path refuses citation-cross-universe, both forms', async t => {
const r = repo(t, { ...BASE, 'Crossover/Chapters/01.md': 'A hybrid fixture.\n', 'Second World/Characters/Ilse.md': 'Ilse fixture.\n',
'Book One/Research/Logs.md': 'Logs fixture.\n' }), c = context(r);
const at = (path, quote) => cite(r.commit, { path, lines: [1, 1], quote });
const hybrid = at('Crossover/Chapters/01.md', 'hybrid fixture');
for (const [exclude, hidden, quote] of [['second/Characters', 'Second World/Characters/Ilse.md', 'Ilse fixture'],
['Book One/Research', 'Book One/Research/Logs.md', 'Logs fixture']]) {
c.mapping = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'],
constraints: [{ exclude }] }; return e; });
c.library = libraryReader(r.repo, r.commit, c.mapping);
await assert.rejects(checkReply(body([hybrid, cite(r.commit), at(hidden, quote)]), c),
e => refusal('citation-cross-universe')(e) && e.reason === `citation 3 (${hidden}) is excluded from hybrid cross`, exclude);
// The hybrid with source canon it can see passes.
assert.equal((await checkReply(body([hybrid, cite(r.commit)]), c)).citations.length, 2, exclude);
// A cite of only the excluded source path is that source universe's item, as before.
assert.deepEqual((await checkReply(body([at(hidden, quote)]), c)).citations.map(x => x.scope), ['canon'], exclude);
}
});
test('C2-06 web cites require allowed definition, this session fetch and kept quote', async t => {
const c = context(repo(t)), web = { url: 'https://fixture.invalid/page', quote: 'kept text' };
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
+97
View File
@@ -0,0 +1,97 @@
// C2-15 (row 267): a hybrid universe end to end. A business-file-shaped mapping (the fixture mapping plus a
// `cross` hybrid over harbor and second) on a repo in her layout's shape, read through the C2-05a queries,
// once for each exclude form of design 1.4. Bodies are made up; the repos are built under the scratch area.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { excluded } from '../src/scope.mjs';
import { find, get, search, universeInfo } from '../src/query.mjs';
import { mapping, MARKER } from './helpers.mjs';
import { scratchRepo, note } from './query-fixture.mjs';
const FILES = {
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', `Grey eyes. ${MARKER}`),
'Book One/Research/Ferry Logs.md': note('research', 'Ferry Logs', `Timetable. ${MARKER}`),
'Book One/Playground/What If.md': note('summary', 'What If', `${MARKER} [[Mara Vell]]`),
'Second World/Characters/Ilse.md': note('character', 'Ilse', `${MARKER} Ilse.`),
'Second World/Places/Gate.md': note('place', 'Gate', `${MARKER} Gate.`),
'Crossover/Chapters/01.md': `# Meeting\n${MARKER} [[Mara Vell]] meets [[Ilse]] at [[Gate]].\nShe reads [[Ferry Logs]].\n`,
'Crossover/Places/Bridge.md': note('place', 'Bridge', `${MARKER} Bridge.`),
};
// The two forms of one rule: the type form hides second's Characters folder; the path form hides harbor's
// Research folder.
const FORMS = {
type: { exclude: 'second/Characters', hidden: 'Second World/Characters/Ilse.md' },
path: { exclude: 'Book One/Research', hidden: 'Book One/Research/Ferry Logs.md' },
};
const NOTES = 'Ilse crosses over after book one.';
const withCross = exclude => mapping(e => {
e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'],
constraints: [{ exclude }, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'name', value: 'Mara Vell' } }],
notes: NOTES };
return e;
});
const src = (ref, map) => ({ repo: ref.repo, commit: ref.commit, mapping: map });
const ALL_CANON = ['Book One/Characters/Mara Vell.md', 'Book One/Research/Ferry Logs.md', 'Crossover/Chapters/01.md',
'Crossover/Places/Bridge.md', 'Second World/Characters/Ilse.md', 'Second World/Places/Gate.md'];
test('C2-15 excluded(): the type form covers the type folder in each source book, the path form its own subtree', () => {
const type = withCross('second/Characters'), path = withCross('Book One/Research');
assert.equal(excluded(type, 'cross', 'Second World/Characters/Ilse.md'), true);
assert.equal(excluded(type, 'cross', 'Second World/Characters/Deep/Ilse.md'), true);
assert.equal(excluded(type, 'cross', 'Second World/Places/Gate.md'), false);
assert.equal(excluded(type, 'cross', 'Book One/Characters/Mara Vell.md'), false);
assert.equal(excluded(type, 'cross', 'second/Characters/Ilse.md'), false);
assert.equal(excluded(path, 'cross', 'Book One/Research/Ferry Logs.md'), true);
assert.equal(excluded(path, 'cross', 'Book One/Research'), true);
assert.equal(excluded(path, 'cross', 'Book One/Research Notes/Other.md'), false);
assert.equal(excluded(path, 'cross', 'Second World/Characters/Ilse.md'), false);
// Not a hybrid, or no such universe: nothing is excluded.
assert.equal(excluded(path, 'harbor', 'Book One/Research/Ferry Logs.md'), false);
assert.equal(excluded(path, 'nowhere', 'Book One/Research/Ferry Logs.md'), false);
});
for (const [form, { exclude, hidden }] of Object.entries(FORMS)) {
test(`C2-15 ${form} form: find and search in the hybrid return its own files and the sources' canon, never a source playground or the excluded path`, async t => {
const s = src(scratchRepo(t, FILES), withCross(exclude));
const found = (await find(s, { universe: 'cross', query: '' })).results;
assert.deepEqual(found.map(r => r.path), ALL_CANON.filter(p => p !== hidden));
for (const r of found) assert.equal(r.universe, r.path.startsWith('Crossover/') ? 'cross' : r.path.startsWith('Book One/') ? 'harbor' : 'second');
assert.deepEqual((await find(s, { universe: 'cross', query: '', scope: 'playground' })).results, []);
const hits = (await search(s, { universe: 'cross', text: MARKER })).results.map(r => r.path);
assert.ok(!hits.includes(hidden) && !hits.includes('Book One/Playground/What If.md'));
assert.ok(hits.includes('Crossover/Places/Bridge.md'));
assert.ok(hits.includes(form === 'type' ? 'Book One/Research/Ferry Logs.md' : 'Second World/Characters/Ilse.md'));
assert.equal(await get(s, { universe: 'cross', path: hidden }), null);
});
test(`C2-15 ${form} form: get keeps the hybrid's links into its sources and drops the excluded target`, async t => {
const s = src(scratchRepo(t, FILES), withCross(exclude));
const chapter = await get(s, { universe: 'cross', path: 'Crossover/Chapters/01.md' });
const out = chapter.links_out.map(l => l.path);
assert.ok(out.includes('Book One/Characters/Mara Vell.md'));
assert.ok(out.includes('Second World/Places/Gate.md'));
assert.ok(!out.includes(hidden));
});
}
test('C2-15 a source universe never sees a hybrid file', async t => {
const s = src(scratchRepo(t, FILES), withCross('Book One/Research'));
for (const universe of ['harbor', 'second']) {
assert.ok(!(await find(s, { universe, query: '' })).results.some(r => r.path.startsWith('Crossover/')), universe);
assert.ok(!(await search(s, { universe, text: MARKER })).results.some(r => r.path.startsWith('Crossover/')), universe);
assert.equal(await get(s, { universe, path: 'Crossover/Chapters/01.md' }), null);
}
const mara = await get(s, { universe: 'harbor', path: 'Book One/Characters/Mara Vell.md' });
assert.ok(!mara.links_in.some(l => l.path.startsWith('Crossover/')));
});
test('C2-15 universeInfo returns the hybrid\'s sources, constraints and notes', async t => {
const s = src(scratchRepo(t, FILES), withCross('second/Characters'));
const info = await universeInfo(s, { universe: 'cross' });
assert.equal(info.kind, 'hybrid');
assert.deepEqual(info.sources, ['harbor', 'second']);
assert.deepEqual(info.constraints, [{ exclude: 'second/Characters' },
{ pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'name', value: 'Mara Vell' } }]);
assert.equal(info.notes, NOTES);
});
+28
View File
@@ -212,6 +212,34 @@ test('a hybrid universe is a shape the query code can take (1.4), with sources t
refuses(e => { hybrid(e); e.universes.cross.constraints = [{ exclude: 'x', pin: {} }]; return e; }, 'library-mapping-invalid', /exactly one/);
});
test('C2-15: each exclude and pin is checked against the hybrid\'s sources', () => {
const cross = (...constraints) => e => {
e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'], constraints };
return e;
};
const pin = entity => ({ pin: { entity, field: 'eyes', value: 'grey' } });
// Both forms, a whole source book and a pin in a source book check.
assert.equal(mapping(cross({ exclude: 'second/Characters' }, { exclude: 'Book One/Research' }, { exclude: 'Second World' },
pin('Second World/Characters/Ilse.md'))).universes.cross.constraints.length, 4);
// Both readings fit: second's book folder is also named "second".
refuses(e => { cross({ exclude: 'second/Characters' })(e); e.universes.second.books = ['second']; return e; },
'library-mapping-invalid', /constraints\[0\]\.exclude "second\/Characters" reads as both a source\/type and a source path/);
// Neither fits: an unmapped path, the hybrid's own book, a type that isn't a key, a non-source universe.
for (const exclude of ['Elsewhere/Characters', 'Crossover/Characters', 'second/Ghosts', 'harbor', 'Book'])
refuses(cross({ exclude }), 'library-mapping-invalid', /\.exclude ".*" lies inside no book of the hybrid's sources/);
refuses(e => { cross({ exclude: 'third/Characters' })(e); e.universes.third = { name: 'Third', books: ['Third World'] }; return e; },
'library-mapping-invalid', /lies inside no book of the hybrid's sources/);
// Under a source's playground, as the prefix itself or inside it.
for (const exclude of ['Book One/Playground', 'Book One/Playground/Ideas'])
refuses(cross({ exclude }), 'library-mapping-invalid', /\.exclude ".*" lies under a playground of universes\.harbor, which the hybrid never reads/);
refuses(cross(pin('Book One/Playground/What If.md')), 'library-mapping-invalid',
/\.pin\.entity "Book One\/Playground\/What If\.md" lies under a playground of universes\.harbor/);
refuses(cross(pin('Crossover/Characters/Mara Vell.md')), 'library-mapping-invalid', /\.pin\.entity .* lies inside no book of the hybrid's sources/);
// Q5: a pinned entity is one .md note.
for (const entity of ['Book One/Characters/Mara Vell', 'Book One/Characters', 'Book One/Board.canvas'])
refuses(cross(pin(entity)), 'library-mapping-invalid', /\.pin\.entity ".*" is not a \.md file/);
});
test('a caller-supplied fail receives the code, as business file loading will pass it (C2-04)', () => {
const seen = [];
const fail = (code, message) => { seen.push(code); throw new Error(message); };
+76 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { readLibrary, parseFrontMatter, LibraryError, MAX_BLOB } from '../src/index.mjs';
import { BASE, git, mapping, repo } from './helpers.mjs';
import { BASE, git, MARKER, mapping, repo } from './helpers.mjs';
const codes = (result, path) => result.problems.filter(p => p.path === path).map(p => p.code);
const file = (result, path) => result.files.find(f => f.path === path);
@@ -150,6 +150,81 @@ test('R6: a canon file that refers into another universe is reported; a hybrid m
assert.deepEqual(codes(result, 'Crossover/Chapters/A.md'), []);
});
// C2-15: a hybrid over harbor and second with one exclude and, optionally, a pin on Mara's eyes.
const crossWith = (...constraints) => mapping(e => {
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'], constraints };
return e;
});
const EYES = { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'eyes', value: 'grey' } };
const character = (name, extra = []) => ['---', 'type: character', ...(name ? [`name: ${name}`] : []), ...extra, '---', MARKER, ''].join('\n');
const MARA_GREY = character('Mara Vell', ['eyes: grey']);
test('C2-15 R6: a hybrid file that links into an excluded source path is reported, in both exclude forms', async t => {
const r = repo(t, { ...BASE, 'Second World/Characters/Ilse.md': character('Ilse'), 'Book One/Research/Logs.md': '# Logs\n',
'Crossover/Chapters/A.md': 'From [[Mara Vell]] and [[Ilse]].\nThen [[Logs]].\n' });
for (const [exclude, line] of [['second/Characters', 1], ['Book One/Research', 2]]) {
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: crossWith({ exclude }) });
assert.deepEqual(result.problems.filter(p => p.path === 'Crossover/Chapters/A.md'),
[{ code: 'R6', path: 'Crossover/Chapters/A.md', line, message: 'canon file refers into another universe' }], exclude);
// The link stays in the file's links, as for every R-rule; the index's sees() is what hides it.
assert.equal(file(result, 'Crossover/Chapters/A.md').links.length, 3);
// A source's own link into the same path is not the hybrid's and stays clean.
assert.deepEqual(codes(result, 'Book One/Chapters/Chapter 01.md'), []);
}
});
test('C2-15 R8: the pinned entity reads clean when its field holds the value, and is reported when missing or changed', async t => {
const at = async files => {
const r = repo(t, { ...BASE, ...files });
return (await readLibrary({ repo: r.repo, commit: r.commit, mapping: crossWith(EYES) })).problems.filter(p => p.code === 'R8');
};
assert.deepEqual(await at({ 'Book One/Characters/Mara Vell.md': MARA_GREY }), []);
// Missing: no line.
const base = { ...BASE }; delete base['Book One/Characters/Mara Vell.md'];
const r = repo(t, base);
assert.deepEqual((await readLibrary({ repo: r.repo, commit: r.commit, mapping: crossWith(EYES) })).problems.filter(p => p.code === 'R8'),
[{ code: 'R8', path: 'Book One/Characters/Mara Vell.md', message: 'pinned value changed' }]);
// Two pins on the missing entity give one entry (Filbert N1).
const twoPins = crossWith(EYES, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'name', value: 'Mara Vell' } });
assert.deepEqual((await readLibrary({ repo: r.repo, commit: r.commit, mapping: twoPins })).problems.filter(p => p.code === 'R8'),
[{ code: 'R8', path: 'Book One/Characters/Mara Vell.md', message: 'pinned value changed' }]);
// Changed, with the key's line; a missing key has no line.
assert.deepEqual(await at({ 'Book One/Characters/Mara Vell.md': character('Mara Vell', ['eyes: green']) }),
[{ code: 'R8', path: 'Book One/Characters/Mara Vell.md', line: 4, message: 'pinned value changed' }]);
assert.deepEqual((await at({})).map(p => [p.path, p.line]), [['Book One/Characters/Mara Vell.md', undefined]]);
// A list holding the value, or null, is not the value (String(['grey']) would be 'grey').
for (const eyes of ['eyes: [grey]', 'eyes:', 'eyes: ~'])
assert.deepEqual((await at({ 'Book One/Characters/Mara Vell.md': character('Mara Vell', [eyes]) })).map(p => p.line), [4], eyes);
// Unparsed (R3) front matter counts as changed.
assert.equal((await at({ 'Book One/Characters/Mara Vell.md': character('Mara Vell', ['eyes: grey', 'bad: {a: 1}']) })).length, 1);
// A number pin compares as text.
const r2 = repo(t, { ...BASE, 'Book One/Characters/Mara Vell.md': character('Mara Vell', ['age: 30']) });
const age = crossWith({ pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'age', value: 30 } });
assert.deepEqual((await readLibrary({ repo: r2.repo, commit: r2.commit, mapping: age })).problems.filter(p => p.code === 'R8'), []);
});
test('C2-15 R8: a hybrid version of the entity that sets the field to another value is reported on that line; a link alone is not a version', async t => {
const read = async files => {
const r = repo(t, { ...BASE, 'Book One/Characters/Mara Vell.md': MARA_GREY, ...files });
return (await readLibrary({ repo: r.repo, commit: r.commit, mapping: crossWith(EYES) })).problems.filter(p => p.code === 'R8');
};
// Same type and name, no link to the entity: a version.
assert.deepEqual(await read({ 'Crossover/Characters/Older Mara.md': character('Mara Vell', ['eyes: blue']) }),
[{ code: 'R8', path: 'Crossover/Characters/Older Mara.md', line: 4, message: 'pinned value changed' }]);
// No name on the version: the file names compare.
assert.deepEqual((await read({ 'Crossover/Characters/Mara Vell.md': character(null, ['eyes: blue']) })).map(p => [p.path, p.line]),
[['Crossover/Characters/Mara Vell.md', 3]]);
// A version that keeps the value, or doesn't set the field, is clean.
assert.deepEqual(await read({ 'Crossover/Characters/Older Mara.md': character('Mara Vell', ['eyes: grey']),
'Crossover/Characters/Young Mara.md': character('Mara Vell') }), []);
// Not versions: a place with the field, a different character that links her, a playground copy, a source file.
assert.deepEqual(await read({
'Crossover/Places/Mara Vell.md': ['---', 'type: place', 'name: Mara Vell', 'eyes: blue', '---', '', ''].join('\n'),
'Crossover/Characters/Ilse.md': character('Ilse', ['eyes: blue', 'relationships:', ' - "[[Mara Vell]]"']),
'Second World/Characters/Mara Vell.md': character('Mara Vell', ['eyes: blue']),
}), []);
});
test('R7: two paths that differ only in case are both left out', async t => {
const r = repo(t, BASE);
writeFileSync(join(r.root, 'twin'), 'twin\n');
+1 -1
View File
@@ -72,7 +72,7 @@ test('with --mapping the survey adds scope, resolver step and problem counts', a
assert.deepEqual(tail, [
'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',
'resolved root 2, vault-prefix 1, relative 2, shortest 3, unresolved 0',
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0, R8 0']);
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
});
+15
View File
@@ -527,3 +527,18 @@ test('C2-03b T5 helper: the named seat\'s user and file, never a link, 0644, ano
assert.ok(statSync(marker).isFile(), 'the swap ran');
assert.equal(readFileSync(file, 'utf8'), 'fixture-token-other-0047\n', 'the slot path names the other file now');
});
test('C2-15 sage-4389 (a): an excluded-link R6 is written to the sync state with the default message and reads back', async t => {
const w = await world(t);
// context.business.projects.books is this same entry, so the run checks the mapping with the hybrid in it.
w.entry.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'],
constraints: [{ exclude: 'Book One/Places' }] };
const next = w.push({ 'Crossover/Chapters/A.md': 'See [[Harbor Town]].\n' });
const { state, code } = await w.run();
assert.equal(code, null);
assert.equal(state.served, next);
const read = await w.state();
assert.equal(read.served, next);
assert.deepEqual(read.problems.filter(p => p.path === 'Crossover/Chapters/A.md'),
[{ code: 'R6', path: 'Crossover/Chapters/A.md', line: 1, message: 'canon file refers into another universe' }]);
});