This document removes recurring implementation choices from individual PRs. It
is the shared handoff contract for a coding agent implementing a file from
planned-prs/.
The coding agent should not redesign these decisions inside a feature PR. If the current code proves a decision impossible or materially harmful, stop and record the conflict instead of silently choosing a different architecture.
When instructions appear to conflict, use this order:
- The active file in
planned-prs/ - This implementation contract
v0.1-spec.mddomain-language.md- The architecture decision records
product-north-star.md
The domain language remains authoritative for the meaning and spelling of Stackdraft concepts at every level.
For each PR:
- Start from current
main. - Read the entire active PR file and every document it references.
- Inspect the current implementation before adding abstractions.
- Implement only the active PR's included scope.
- Add or update tests for each acceptance criterion.
- Run
deno task ci. - Update
project-evolution.mdonly when the work teaches a durable product or process lesson; do not add a routine changelog entry. - Delete the active PR file as the final implementation change.
Do not implement preparatory behavior assigned to a later PR. A small interface needed by the current slice is allowed; speculative fields, endpoints, components, and extension systems are not.
Use this prompt with the active plan path substituted:
Implement docs/planned-prs/NN-plan-name.md.
Read that file and every document it marks as required context before changing
code. Follow docs/implementation-contract.md. Inspect the current repository and
merged dependencies first. Implement only the Included scope and fixed details;
do not pull work forward from later PRs.
Add tests for every acceptance criterion, run deno task ci, and delete the active
plan file as the final implementation change. Update project-evolution.md only
if the work reveals a durable product or process lesson. Do not commit or push.
If current code materially conflicts with the plan, stop and explain the exact
conflict instead of silently redesigning the contract.
Use these boundaries as the codebase grows:
api/
├── defs/ Shared type and schema definitions only
├── core/ Resource use cases, validation, store contracts, error catalog
├── infrastructure/ Runtime adapters: HTTP, SQLite, process-facing code
├── lib/ Generic reusable mechanics, not Stackdraft concepts
├── commands/ CLI entry points
├── config.ts
└── main.ts
New backend resources use this structure:
api/defs/<resource>/
├── <resource>.ts Entity/shared types, unions, and definition constants
└── <resource>-schema.ts Effect schemas and inferred schema types
api/core/<resource>/
├── input.ts Use-case input types
├── service.ts Public service facade: interface, tag, accessors
├── service-live.ts Service implementation and use-case orchestration
├── store.ts Core store contract
└── validation.ts Resource-specific validation helpers
api/infrastructure/database/
└── <resource>-store.ts SQLite implementation of the core store contract
Open files in this order when learning or extending a resource:
api/core/<resource>/service.tsshows what the resource can do.api/core/<resource>/service-live.tsshows how use cases are implemented.api/core/<resource>/store.tsshows which data operations core needs.api/infrastructure/database/<resource>-store.tsshows SQLite details.api/defs/<resource>/shows shared type and payload definitions.api/core/errors.tsshows every backend tagged error in one catalog.
Rules:
api/defscontains definitions only: interfaces, type aliases, schemas, schema-derived types, and constants that define unions. It must not contain validation functions, mapping functions, services, stores, adapters, or runtime logic.api/coreowns application behavior. It must not import Oak, SQLite, filesystem, environment variables, or HTTP request/response types.api/core/<resource>/service.tsis the public facade. Keep it scan-friendly; move implementation detail intoservice-live.ts,validation.ts,input.ts, orstore.ts.api/core/<resource>/store.tsdefines the storage contract that core depends on. It is not the database implementation.api/core/errors.tsis the only place backendData.TaggedErrorclasses are defined. Group errors by area in that file so maintainers can audit all error tags without grepping the repository.api/infrastructure/httptranslates HTTP into core operations. It does not execute SQL.api/infrastructure/databaseimplements store contracts. It must not know about HTTP requests or responses.- Inside resource folders, use short filenames because the path carries the
resource name:
service.ts, notstate-service.ts.
Use these boundaries as the frontend grows:
frontend/src/
├── api/ Typed fetch functions and entity API clients
├── app/ Router and application shell
├── features/ State, Stack, and Draft feature UI
├── lib/ Shared frontend mechanics
└── styles/ Tokens and global/shared styles
- React modules use the typed frontend API client and never import backend modules. Shared contracts may be extracted later only when duplication proves harmful.
- Keep feature-specific frontend components together under
frontend/src/features/<feature>/.
Shared lib code extracts repeated mechanics, not domain concepts. Add a helper
only when the current code has at least two real call sites or when the helper
protects a cross-cutting invariant such as request decoding, typed error
response mapping, Effect execution, transaction rollback, SQLite error
detection, shared validation primitives, API error decoding, or form error
splitting. Call sites must still read in domain language.
Backend and frontend have separate lib roots:
api/lib/
├── effect/ Effect runtime boundary helpers
├── http/ HTTP request, response, and API error mechanics
├── sqlite/ SQLite row, transaction, and generic error mechanics
├── time/ Generic UTC DateTime conversion and formatting
└── validation/ Shared validation primitives
frontend/src/lib/
├── api/ Fetch response and API error mechanics
├── async/ Browser async state helpers
└── forms/ Generic form/error helpers
Use these guardrails:
- Resource definitions and core rules stay in
api/defsorapi/core, not inapi/lib. - Error definitions stay in
api/core/errors.ts; error handling, mapping, and serialization helpers may live in infrastructure or lib when they are generic mechanics. - Entity-specific store behavior stays in store implementations, not in generic table mappers.
- Entity API clients stay in
frontend/src/api/; frontend lib code may decode generic response mechanics but must not own State, Stack, or Draft contracts. - UI components remain in feature folders unless a second real feature needs the same component shape.
- Do not introduce a root-level shared package, generic CRUD route generator, store base class, global state library, or data-fetching library in v0.1.
- A wrapper should remove repeated mechanics while keeping HTTP method/path, domain operation, response status, and encoder visible at the call site.
- Use Effect for core services, store dependencies, configuration, typed failures, and lifecycle.
- Define store contracts and core services with
Context.Tag. - Provide live implementations with
Layer. - Keep constructors such as
makeStateServiceavailable when they make unit testing simpler without a complete runtime. - Convert Effects to Promises only at the process or HTTP composition boundary.
api/main.tsowns live SQLite layers, the managed runtime, and Oak dependency wiring.- HTTP tests inject simple Promise-returning fakes into
createApp; they should not need a real database. - Expected domain failures must remain typed until the HTTP error mapper
converts them. Do not catch every failure and return
500.
The existing health slice is the style reference, not a requirement to keep all future code in the same files.
Code should first communicate through precise names, small coherent units, and visible control flow. Do not use comments to compensate for avoidable naming, nesting, or responsibility problems.
Any important ambiguity that remains after reasonable refactoring must be explained next to the code. Comments are expected when understanding or safely changing the code depends on information that syntax alone does not reveal, including:
- why an abstraction or wrapper exists and what responsibility it centralizes;
- ordering requirements between middleware, effects, cleanup, or lifecycle boundaries;
- concurrency, idempotency, mutation scope, and exactly-once invariants;
- security, privacy, data-integrity, or error-propagation constraints;
- behavior that is deliberately different from the most obvious implementation; and
- compatibility or tool constraints that prevent a simpler local design.
Complex functions and methods should have a short leading comment or doc comment that states their purpose and the non-obvious parts of their contract. Call out important side effects, failure propagation, or orchestration responsibilities when those are not clear from the signature and name. Internal helper comments should explain why the steps relate, not narrate each statement.
Keep comments concise, adjacent to the behavior they protect, and accurate. Update or delete affected comments in the same PR as the code. Do not require a comment for a self-explanatory symbol or straightforward branch, and do not add comments that merely restate the code in prose.
Backend operational logs use the shared structured logger under
api/lib/logging/. Application code must not introduce a second logger or use
direct console calls for operational events. Deliberate CLI output intended
for a person, such as command usage, a success confirmation, or an explicitly
opt-in local-development diagnostic, may continue to use console. Such output
must be disabled by default in production and must not pass through logger
sinks.
Logging follows these rules:
- Emit one JSON object per line.
debugandinfouse stdout;warnanderroruse stderr. - Use events, services, outcomes, and resources from the static TypeScript catalogs. Do not create ad hoc event strings at call sites.
- Create scoped loggers with
.with(context). The logger is immutable; stable service, method, request, route, and resource context should be attached before the event point. - Represent product identities as
resources: [{ type, id }], not fields such asstateId,stackId, ordraftId. - Use the request-scoped logger from Oak context inside HTTP routes. Do not use global mutable state or add async context propagation for v0.1.
- Use matched route patterns when available and otherwise use only the request pathname. Never log query values, request or response bodies, headers, SQL, credentials, dependency objects, or arbitrary object dumps.
- Put narrowly scoped event metadata in scalar-only
fields. Database paths are logged only as an operational category, never as the configured path. - Pass failures through the logger's
causeinput. The logger owns bounded, defensive error serialization. Tagged application errors may contribute their safe message; untagged and nested dependency causes are reduced to their shape so SQL, paths, credentials, and dependency details cannot cross the logging boundary. Messages on tagged errors must not repeat raw input or dependency values. Do not serialize errors or dependency values at call sites. - Stack traces from tagged application errors are available only for debug
entries or when the configured minimum level is
debug. Raw dependency stacks are never emitted. - Logger sinks are failure-isolated. A broken stdout, stderr, or replacement sink must not change application, command, or request behavior.
HTTP request lifecycle logs use info for successful responses, warn for
handled 4xx responses, and error for handled 5xx or unhandled failures. An
unhandled failure produces one request_failed entry before the HTTP boundary
maps it to a sanitized 500 response; it does not also produce a completion
entry.
Create a root logger only at an application or CLI composition root. Lower
layers receive a Logger dependency, while Oak route code starts from the
request-scoped logger in context.state.logger. Do not call createLogger
inside services, stores, route handlers, or reusable helpers.
const rootLogger = createLogger({
minimumLevel: config.logLevel,
context: { service: "app", method: "main" },
});Before emitting a new kind of entry, add its vocabulary to the appropriate
catalog in api/lib/logging/events.ts, api/lib/logging/services.ts, or
api/lib/logging/resources.ts. Reuse an existing event only when it represents
the same operational occurrence; do not choose a vaguely related event merely to
satisfy the type checker.
Scope stable context once before the operation, then emit the event at the boundary that understands its outcome:
const operationLogger = context.state.logger.with({
service: "state",
method: "updateState",
resources: [{ type: "state", id: stateId }],
});
try {
return await updateState(stateId, input);
} catch (cause) {
operationLogger.error({
event: "state_persistence_failed",
message: "Failed to persist State changes.",
outcome: "failure",
cause,
});
throw cause;
}Non-HTTP boundaries follow the same pattern with an injected logger:
const migrationLogger = logger.with({
service: "migration",
method: "migrate",
});
migrationLogger.info({
event: "migration_started",
message: "Started database migration.",
});Use fields only for small event-specific scalar values that developers may
filter or compare. Put stable execution identity in logger context, product
identity in resources, and failures in cause. Keep message short and
human-readable; searchable semantics belong in the typed event and context.
Prefer existing boundary helpers such as request middleware and route wrappers when they already own the relevant logging behavior. Do not log an error and rethrow it at every layer. Multiple entries for one failure are justified only when they describe distinct operational events, such as a persistence failure and the resulting failed HTTP request.
- Repository-owned file and directory names use lowercase kebab-case.
- Separate words with one hyphen:
state-store.ts,state-store-test.ts, andread-only-state-catalog.md. - Single lowercase words such as
app.ts,config.ts, andmigrations/already satisfy the convention. - TypeScript symbols keep their normal conventions: React components, classes, and types remain PascalCase; functions and values remain camelCase.
- Do not use camelCase, PascalCase, snake_case, or spaces in new path names.
- Future migrations use
NNNN-description-in-kebab-case.sql. - A PR that introduces or changes a repository convention must update every affected tracked file in the same PR. Do not leave known violations for a later cleanup PR.
- Conventions should simplify development, not require compatibility workarounds. When a tool has a strong native naming requirement, document the narrow exception and use the native behavior.
Exceptions are limited to names required or strongly established by tooling and
platforms, including README.md, Dockerfile, LICENSE, dotfiles,
package.json, deno.json, deno.lock, vite.config.ts, and GitHub workflow
paths. Deno test modules retain the natively discovered *_test.ts suffix.
Generated dependency files are outside this rule.
Merged migrations are immutable. migrations/0001_initial.sql keeps its
existing name; it is not renamed retroactively.
- IDs are lowercase UUID strings generated with
crypto.randomUUID(). Definitions and schemas must type IDs with UUID-specific primitives such asSchema.UUID, not plain strings. - API timestamps are UTC ISO-8601 values. Definitions and schemas must type
timestamps with DateTime-specific primitives such as
Schema.DateTimeUtc, not plain strings. - Numeric ordering fields such as positions must be typed as integers with
integer-specific primitives such as
Schema.IntorSchema.NonNegativeInt, not plain numbers. - Services receive
generateId: () => stringandnow: () => Datewhen they create or update records. Live wiring usescrypto.randomUUIDandnew Date; tests use deterministic fakes. - Trim user-entered names and titles before validation and persistence.
- Preserve internal whitespace and description formatting.
- State names: 1–40 characters after trimming.
- Stack and Draft titles: 1–160 characters after trimming.
- Descriptions: 0–20,000 characters.
- State colors: exact CSS hex form
#RRGGBB, accepted case-insensitively and stored lowercase. The shared CSS hex primitive lives inapi/lib/validation; State-specific validation owns the field name and user-facing error message. - PATCH requests must contain at least one recognized mutable field.
- Entity scope, IDs, and creation timestamps are immutable. A Draft's optional
Stack association is mutable through
UpdateDraftBody.
Character limits count JavaScript string length in v0.1. Grapheme-aware limits are unnecessary for this proof of concept.
- Every schema change is a new ordered migration; never edit a migration already
merged to
main. - Use
STRICTtables. - Use
TEXTfor IDs and timestamps andINTEGERwithCHECK (... IN (0, 1))for booleans. - Use
COLLATE NOCASEwhere the schema requires case-insensitive uniqueness. - Positions are zero-based, contiguous within their scope, and sorted ascending.
- Foreign keys use
ON UPDATE RESTRICT ON DELETE RESTRICT. - Store implementations map snake_case database rows to camelCase core values.
- Multi-row invariant changes use
BEGIN IMMEDIATEtransactions and fully roll back on failure. - Translate expected SQLite constraint failures into typed application errors; do not expose SQL text to clients.
- Queries with user values use prepared statements.
Migrations run automatically at startup. Migration tests must assert the new version, required seed or schema behavior, and idempotence.
- API paths and query parameter names are lowercase/camelCase exactly as written
in
v0.1-spec.md. - JSON fields are camelCase.
- Entity responses contain the entity directly.
- Collection responses use a named envelope:
{ "states": [...] },{ "stacks": [...] }, or{ "drafts": [...] }. - Successful creation returns
201. - Successful reads and updates return
200. - Successful deletion returns
204with no body. - Request bodies use
application/json. - Reject malformed JSON, unknown fields, invalid query parameters, and invalid path IDs.
- Use Effect Schema at the HTTP boundary with excess-property rejection.
- Do not return stack traces, SQL messages, filesystem paths, or dependency details.
Collection ordering is deterministic:
- States:
position ASC, thenid ASC - Stacks:
created_at DESC, thenid ASC - Drafts:
created_at DESC, thenid ASC
type CreateStateBody = {
scope: "stack" | "draft";
name: string;
color: string;
};
type UpdateStateBody = {
name?: string;
color?: string;
};
type MoveStateBody = {
position: number;
};
type CreateStackBody = {
title: string;
description?: string;
stateId?: string;
};
type UpdateStackBody = {
title?: string;
description?: string;
stateId?: string;
};
type CreateDraftBody = {
title: string;
description?: string;
stateId?: string;
stackId?: string | null;
};
type UpdateDraftBody = {
title?: string;
description?: string;
stateId?: string;
stackId?: string | null;
};Setting a default State requires no request body. State scope is immutable.
All API errors retain this shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request is invalid.",
"details": {
"fields": {
"name": "Name is required."
}
}
}
}details is always an object. Field details are included when a particular
input caused the failure and omitted otherwise.
Use these stable codes:
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR |
Malformed or invalid body, query, or path input |
| 400 | INVALID_STATE_SCOPE |
A State belongs to the wrong entity scope |
| 404 | STATE_NOT_FOUND |
Requested State does not exist |
| 404 | STACK_NOT_FOUND |
Requested Stack does not exist |
| 404 | DRAFT_NOT_FOUND |
Requested Draft does not exist |
| 409 | STATE_NAME_CONFLICT |
State name already exists in its scope |
| 409 | STATE_IN_USE |
Stack or Draft currently references the State |
| 409 | STATE_IS_DEFAULT |
State is the current default for its scope |
| 409 | LAST_STATE_IN_SCOPE |
Deletion would leave a scope without States |
| 500 | UNKNOWN_ERROR |
Unexpected failure |
| 503 | SERVICE_UNAVAILABLE |
Health dependency unavailable |
Malformed UUIDs are validation errors. A syntactically valid but absent filter
ID returns an empty collection. A filter that names a State from the wrong scope
returns INVALID_STATE_SCOPE.
GET /api/statesrequires exactly onescope=stack|draftquery parameter.- Creating a State appends it after the current final position in its scope and creates it as non-default.
- Updating a State changes only
name,color, andupdatedAt. - Moving a State shifts the affected inclusive range and updates every changed
row in one transaction. Positions outside
0..count-1are invalid. PUT /api/states/:stateId/defaultmakes that State the sole default in its scope and returns the updated State.- Deletion checks constraints in this order: not found, default, last in scope, then foreign-key use during deletion. Eligible deletion compacts later positions.
- Omitted descriptions become
"". - Omitted
stateIdresolves to the current default for the correct scope inside the create operation. - Supplying a valid but missing State ID returns
STATE_NOT_FOUND. updatedAtequalscreatedAton creation and changes on successful mutation.- Stack and Draft collection filters are optional.
- Draft routes use
draftIdas the Draft's identity and never require a parent Stack path. - Draft collection filtering accepts optional
stateIdandstackIdquery parameters. With neither filter it returns all Drafts, including standalone Drafts and Drafts assigned to a Stack. Filters compose when both are present. - A syntactically valid but absent
stackIdcollection filter returns an empty collection. Creating or updating a Draft with an absent Stack returnsSTACK_NOT_FOUND. - Omitted or
nullstackIdon create produces a standalone Draft. On update, omittedstackIdpreserves the association, a UUID assigns or changes it, andnullremoves it. - Draft responses always contain
stackId, using JSONnullwhen standalone. - Stack and Draft deletion do not exist in v0.1.
- Use React Router in declarative/library mode with
BrowserRouter, not a framework-mode setup or generated route system. - Use semantic HTML and native controls before introducing abstractions.
- Keep server-owned data in feature hooks or components using
fetch; do not add a global state library or data-fetching library in v0.1. - Every request started by an effect uses an
AbortController. - The frontend API layer decodes the standard error body into a typed
ApiError; UI components do not parse arbitrary response JSON. - Forms disable duplicate submission while a mutation is pending.
- On success, update from the server response or refetch; do not fabricate IDs, timestamps, positions, or defaults in the browser.
- Validation errors appear beside the relevant field. Non-field errors appear in
an
aria-liveregion. - State color is always accompanied by its name.
- All interactions must work without drag-and-drop or pointer-only controls.
- Reuse the existing CSS token layer. Add a token when a value is genuinely shared; avoid a component framework and premature design system.
Planned PR 07 introduces the frontend test harness using Vitest, jsdom, React
Testing Library, and user-event, all pinned through the Deno lockfile. It also
splits the tasks into test:api and test:web; deno task test runs both.
Later UI PRs test user-visible behavior rather than component internals or
snapshots.
- Store tests use an in-memory SQLite database with migrations applied.
- Service tests use deterministic IDs and clocks.
- HTTP tests use
app.handle(new Request(...))and injected operations. - UI tests query by accessible role, name, label, and visible text.
- Test the success path plus validation, not-found, conflict, and rollback paths introduced by the active PR.
- Update the
checktask when new independent test entry points are added; imported source modules are checked transitively. deno task cimust remain the one complete local merge gate. It includes the isolated full API QA suite (deno task qa:api:full) and must fail when the assembled HTTP API check fails.deno task qa:api:smokeis the safe read-only check for an already running local API.deno task qa:api:fullis the merge-blocking isolated check used bydeno task ci.- API PRs that add externally visible endpoint behavior must extend
qa/api-suite.tswith smoke and/or full coverage for the new contract while keeping store, service, and HTTP route tests as the primary correctness mechanism. - Do not weaken strict TypeScript, lint, formatting, migration safety, or the existing health behavior to make a feature pass.