Placements developer guide
Placements owns volunteer affiliation, school placement, demand, rosters, and dated school-service commands.
This guide serves consumers of the Placements context and maintainers of its implementation.
Its portable contracts live in packages/domain/src/placements; its PostgreSQL implementation lives in packages/database/src/placements.
The module documentation roadmap defines the documentation contract.
Purpose and ownership
The intended system owns business meaning. Its dated service rules define valid outcomes. The architecture owns dependency direction and runtime responsibilities.
Placements does not own sign-in, HTTP preconditions, HTTP response receipts, or provider configuration. The server entry point also exposes reviewed assignment-import helpers. Those helpers are migration tools, not a shortcut for ordinary user commands. The reference lists them because the export map exposes them, not because this guide authorizes a migration.
The domain manifest and the database manifest own supported imports:
@vektorprogrammet/domain/placements: portable schemas, decisions, failures, and the service key.@vektorprogrammet/database/placements: database-backed composition, delivery operations, and cohort import.
No other Placements import path is supported. Private source links explain implementation. They do not authorize private imports or direct table integration.
Use it
Prerequisites
Use the toolchain from the root manifest and lockfile.
From the repository root, install dependencies with bun install --frozen-lockfile.
The commands below run from that root.
They need no credentials, external provider, PostgreSQL server, browser, or E2E CI branch.
Run the examples
-
Run the application compiler check for both packages:
bun run --cwd packages/domain check-types bun run --cwd packages/database check-types -
Run both executable examples:
bun run --cwd tools/placements-docs docs:examples
The first example prints Absent -> Pending; repeated Request rejected; Withdraw -> Inactive.
It checks a legal transition, a rejected repeat, and recovery through withdrawal.
The helper returns null for a rejected transition. It does not write state or grant authority.
The second example prints Empty scopes read; unknown department rejected with scope.invalid (422).
It composes the real Placements Layer with an empty, disposable PGlite database.
It checks a successful scope read and a typed failure for an unknown department.
A caller must select an existing scope before another attempt. Another attempt with the same missing scope does not recover it.
The following inclusions render the exact executable files. In a Markdown source viewer, open placements-affiliation.ts and placements-read-scopes.ts.
import assert from "node:assert/strict";
import { nextAffiliationStatus } from "@vektorprogrammet/domain/placements";
import { Console, Effect } from "effect";
const requested = nextAffiliationStatus("Absent", "Request");
assert.equal(requested, "Pending");
const repeated = nextAffiliationStatus(requested, "Request");
assert.equal(repeated, null);
// A rejected transition leaves the caller's current status unchanged.
const current = repeated ?? requested;
assert.equal(current, "Pending");
assert.equal(nextAffiliationStatus(current, "Withdraw"), "Inactive");
Effect.runSync(Console.log("Absent -> Pending; repeated Request rejected; Withdraw -> Inactive"));import assert from "node:assert/strict";
import { DatabaseTestLive } from "@vektorprogrammet/database/test-support/platform";
import {
DepartmentId,
OrganizationPersonAuthoritySchema,
PersonId,
} from "@vektorprogrammet/domain/organization";
import { Placements } from "@vektorprogrammet/domain/placements";
import { PlacementsLive } from "@vektorprogrammet/database/placements";
import { Console, Effect, Layer } from "effect";
// Synthetic, unprivileged authority for an empty disposable database.
// A production caller resolves current authority inside its own transaction.
const authority = OrganizationPersonAuthoritySchema.make({
personId: PersonId.make("documentation-reader"),
evaluatedAt: "2026-09-24T00:00:00.000Z",
globalAdministrator: "Absent",
memberships: [],
nationalBoardSeats: [],
delegations: [],
});
const program = Effect.gen(function* () {
const placements = yield* Placements;
const scopes = yield* placements.listScopes(authority);
assert.deepEqual(scopes, { departments: [], semesters: [] });
const rejected = yield* placements
.readOwnAffiliation(authority.personId, DepartmentId.make("missing-department"))
.pipe(
Effect.match({
onFailure: (failure) => failure,
onSuccess: () => assert.fail("An unknown department must not produce an affiliation"),
}),
);
assert.equal(rejected._tag, "PlacementFailure");
assert.equal(rejected.code, "scope.invalid");
assert.equal(rejected.status, 422);
yield* Console.log("Empty scopes read; unknown department rejected with scope.invalid (422)");
});
// DatabaseTestLive owns in-memory PGlite, canonical migrations, and database release.
const layer = PlacementsLive.pipe(Layer.provide(DatabaseTestLive()));
const controller = new AbortController();
const interrupt = () => controller.abort();
process.once("SIGINT", interrupt);
process.once("SIGTERM", interrupt);
try {
await Effect.runPromise(program.pipe(Effect.provide(layer)), { signal: controller.signal });
} finally {
process.removeListener("SIGINT", interrupt);
process.removeListener("SIGTERM", interrupt);
}These examples do not prove HTTP authorization, PostgreSQL concurrency, business-write atomicity, or real-provider delivery. The synthetic authority does not authenticate a real person. PGlite evidence is embedded-database composition evidence, not production database acceptance.
Generate the guide and API reference
Choose a new output directory outside the repository:
bun run --cwd tools/placements-docs docs:generate /tmp/placements-guide
bun run --cwd tools/placements-docs docs:check /tmp/placements-guideOpen /tmp/placements-guide/index.html in a local browser.
The generated navigation contains the @vektorprogrammet/domain/placements and @vektorprogrammet/database/placements modules, with signatures, source comments, and links to their declarations.
The reference contains only declarations reachable through those two export-map entries.
It does not present private adapters as entry points.
Source links in generated HTML open raw file copies. Markdown heading fragments work in repository viewers, not in the browser view of a raw Markdown file. For a linked business rule, find its named heading in that source.
Generation refuses an existing output directory. On failure, generation removes its new partial output directory. The freshness check renders into a temporary directory and compares every generated file without changing the supplied output. A stale or missing output causes a nonzero exit. After a source change, generate into another new directory. Generated output is local and untracked. No hosted documentation website is necessary.
Contract
The public service declaration owns API-specific guarantees and typed dependencies.
The generated PlacementsOperations reference renders those comments directly.
The schemas own accepted values and results.
The policy declarations own decision functions and failure codes.
This guide explains their relationship without maintaining a second signature list.
PlacementsLive captures Database when the Layer acquires its service.
Service operations therefore do not request Database again in their public requirements.
The precondition callback can add its own Effect requirements and typed failures.
An Effect requirement expresses composition, not permission to act for a person.
| Caller concern | Contract and response |
|---|---|
| Authority | The caller authenticates and authorizes each operation. The service key is not an authority token. |
| Command identity | The transport derives commandId from its receipt identity. An arbitrary identifier does not provide HTTP replay protection. |
| Precondition | The callback compares the locked snapshot with the caller's transport precondition. It does not grant authority or perform business writes. |
| Domain rejection | PlacementFailure supplies a tagged failure, code, and status. The caller corrects the relevant scope or command before another attempt. |
| Persistence failure | PlacementPersistenceError distinguishes transaction conflicts from internal errors. Its cause stays internal. |
| Success | execute returns a snapshot within the caller's transaction. This is not yet a commit or a provider acknowledgement. |
The HTTP adapter shows the complete production call boundary. It resolves current authority and receipt identity before the service executes. It evaluates the ETag precondition under the department lock. A read preflight or a successful pure policy calculation does not replace this boundary.
Compose it
The executable read example shows the minimum server Layer composition through public imports.
DatabaseTestLive owns its in-memory database, canonical migrations, and release finalizer, and reads the migrations through the Bun file system.
Effect.provide scopes that Layer to the program.
The signal handler requests interruption. The finally block removes only the example's signal listeners.
No database directory, port, child process, credential, or provider request belongs to these examples.
Abrupt process termination, such as SIGKILL, does not run JavaScript finalizers.
Production composition differs from this fixture:
- Database Layers supply the adapter and run migrations.
- Backend composition shares the database Layer and supervises workers.
- HTTP command transactions own authority preparation, receipt lookup, replay, and transaction completion.
- Notification worker supplies the roster interpreter and recovery schedule.
A new server caller must preserve those responsibilities rather than call execute without an authorized transaction.
The read example deliberately performs no mutation and requires no synthetic command receipt.
How it works
HTTP caller: current authority + receipt identity
-> caller transaction and receipt lookup
-> Placements: department lock
-> snapshot -> transport precondition -> transition
-> business facts + audit/history + required outbox work
-> response receipt -> commit
-> worker claim transaction -> interpreter -> delivery acknowledgementThe service implementation chooses the mutation path and holds the department lock. The placement adapter and coverage adapter implement reads and durable transitions. Their table layouts are private implementation details.
The department lock keeps the checked snapshot and subsequent transition within one serialized department operation. The caller transaction keeps business facts, audit/history, required notifications, and the response receipt together. The service checks cover rejected preconditions and rollback when the caller cannot finish its receipt. The guide does not replace these behavioral checks with source-text assertions.
Placement drafts
readDraft drafts one department and semester and writes nothing.
The draft read collects the inputs; the pure scheduler drafts from them.
- Open demand: the demand of each school, weekday, and block, capped by the school's capacity plan for that weekday, less the active placements there. A school without a capacity plan for a weekday is bounded by its demand alone.
- Supply: each active affiliation without an active placement in the semester.
placementSupplyOfturns the availability that Admissions records into weekdays and blocks; an eight-week position serves both blocks. The latest returning registration counts first. Otherwise a new applicant's application counts once the interview is conducted. A person with neither record is unplaced with the reasonNoAvailability. - School wishes: the teaching language, and the preferred school of a returning registration, travel with each drafted and unplaced assistant. The dashboard shows them to Skolekoordinering. The scheduler does not score them.
draftPlacements is the legacy scheduler: a greedy first fit, improved by annealing and restarted from shuffled weekdays.
The fixed placementDraftSeed makes the same inputs give the same draft.
A coordinator applies the chosen rows as ordinary Create commands against the board ETag that the draft names.
Retry and interruption
The Placements service does not install a retry policy.
The HTTP adapter selects serialization-once in the receipt transaction owner.
That owner repeats preparation and execution after a retryable rollback, so authority must remain inside preparation.
Provider I/O must remain outside the business transaction.
The database adapter owns transaction finalization on Effect failure or interruption. Interruption is not a guarantee that a command did not commit. If the caller loses the response, the transport receipt protocol resolves the uncertain result. A caller must not invent a new command identity merely because the first response is missing.
Outbox and delivery
Roster delivery claims durable work before invoking an interpreter. It fences acknowledgement updates with the claim identity. The worker can recover stale claims after interruption or process loss. An interpreter can succeed before acknowledgement persists, so a later delivery attempt can repeat the external effect. The provider boundary must handle the stable logical effect identity. These functions do not promise exactly-once external delivery.
A committed command means durable business state and required queued work. An interpreter success means that interpreter returned successfully, not that a real provider satisfied a separate acceptance contract. The durable-effects rules remain authoritative.
Change and check it
Bounded reader exercise
An independent reader can perform these tasks without private implementation coaching:
- Find this guide from the repository README.
- Run the compiler and example commands in Use it.
- Explain the repeated-request rejection and the missing-scope failure.
- Generate the guide and locate
PlacementsOperations.executein the reference. - Identify the authority, transaction, and notification owners from the linked sources.
For a bounded change, use a disposable copy or a clean branch:
- Add a sentence to the public
readOwnAffiliationcomment that clarifies its missing-department failure. - Run the compiler checks and
docs:exampleswith the commands above. - Run
docs:checkagainst the earlier output. Expect a nonzero stale-output result. - Generate into a new directory. Then run
docs:checkagainst it. Expect success. - Read the rendered comment beside the generated signature.
- Restore the disposable source change. Remove only the directories from this exercise.
This exercise changes documentation, not the public API or business rules. An agent reader is useful evidence for command completeness, but it does not prove comprehension by an unfamiliar human developer.
Required checks and limits
| Change or failure | Relevant check | Limit |
|---|---|---|
| Broken public import or invalid TypeScript | bun run --cwd packages/domain check-types and bun run --cwd packages/database check-types | Compiler success does not prove runtime behavior. |
| Wrong example result or failure handling | bun run --cwd tools/placements-docs docs:examples | The examples cover only their declared pure and PGlite paths. |
| Changed declaration, comment, example, or generated file | bun run --cwd tools/placements-docs docs:check /tmp/placements-guide | Freshness is not a review of explanatory meaning. |
| Broken guide link or API reference | Generation and its link validation | External URL availability requires separate review. |
| Changed durable command behavior | Existing focused service checks and the golden command below | A documentation-only change does not require another E2E implementation. |
The local school-service gate owns prerequisites for this accepted real-boundary command:
just golden school-serviceThat command exercises real browser, HTTP, PostgreSQL, and loopback notification boundaries. Its evidence does not establish real-provider acceptance or visual usability. Documentation commands do not modify the golden runner.
CI and retained artifacts
The Placements documentation job runs on main pushes and pull requests without provider credentials.
It checks the public examples with the application compiler, runs them, and generates the reference once.
TypeDoc checks guide links. The command also checks repository source paths, line numbers, and source URLs.
Local generation uses file: source links. CI generation uses immutable GitHub URLs with the exact source revision.
Inherited dependency signatures do not receive false repository source links.
CI artifacts include only generated files and tracked public files copied by TypeDoc.
Includes and copied media cannot traverse symlinks or read untracked or hidden paths.
From a clean checkout, run the CI command:
export PLACEMENTS_DOCS_EXPECTED_REVISION="$(git rev-parse HEAD)"
bun run --cwd tools/placements-docs docs:ci /tmp/placements-ci-guideBefore you consume a retained CI artifact, check it against the expected checkout revision:
bun run --cwd tools/placements-docs docs:accept /tmp/placements-ci-guideSupply the expected revision from the checkout or trusted workflow, never from the artifact receipt. The receipt binds complete output to that clean revision and an exact SHA-256 file inventory, including copied media. The consumer rejects missing output, changed files, extra files, symlinks, incomplete receipts, dirty source, and revision drift. This check does not regenerate documentation. A changed guide, example, or source commit rejects an earlier artifact. The receipt is not a signature. Use artifacts from a trusted workflow; rewriting both files and receipt can defeat content hashes.
Use docs:check for retained local output from docs:generate, including uncommitted documentation edits.
It renders current source into a temporary directory and compares the full file inventory and content.
Local and CI artifacts have different source links and are not interchangeable.
Local checks do not prove hosted execution, artifact-service acceptance, branch-protection configuration, or remote URL availability.
The job can serve as a required check, but repository administrators must configure that policy separately.
Tool choice
The documentation tool manifest pins TypeDoc and its compatible documentation-only TypeScript compiler. The application and example compiler remains the version in the domain and database manifests. The separate parser compiler is not an application type-check substitute.
The bounded Effect docgen qualification used @effect/docgen 0.5.2 against the actual package sources and TypeScript 7.0.2.
Its installation reported an unsupported TypeScript peer range.
With version enforcement disabled, extraction failed on 26 undocumented barrel reexports.
This result does not establish that every docgen feature is incompatible with the runtime.
The pilot avoids duplicate barrel documentation and uses TypeDoc to follow the existing reexports instead.
TypeDoc generates the local reference, renders this guide, and includes the exact example files through its built-in include directive.
The Placements documentation command derives entry points from the ./placements export of each manifest and compares fresh output.
It does not parse TypeScript itself.
The documentation site renders this guide beside the other repository documents and links the generated reference.
The HTTP generator remains the owner of HTTP reference artifacts.
Cleanup and evidence
The examples release their embedded database on normal completion, failure, and requested interruption.
The freshness command removes its own temporary render directory in a finally block.
On SIGINT or SIGTERM, the documentation command finishes its current TypeDoc operation, fails, and removes its temporary output.
The CI command stops each compiler or example process group, even after its leader exits.
It allows one second after SIGTERM, then uses SIGKILL and checks removal for one more second.
A cleanup failure cannot produce successful documentation or replace the original subprocess failure.
SIGKILL of the documentation command itself cannot run this cleanup.
After successful generation, the output directory belongs to the caller. The tools never replace a pre-existing directory.
After review, remove only the generated directory that the command created for you.
Acceptance records belong outside tracked source. A record must identify the source commit, exact tools, commands, negative checks, reader type, cleanup, and unverified boundaries. Neither compiler success nor generated prose establishes semantic correctness.