@tsfpp/agents 1.3.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ # Write changelog entry
2
+
3
+ Inspect the current working tree, derive the correct conventional commit message,
4
+ and append a matching entry to the `## [Unreleased]` section of `CHANGELOG.md`.
5
+
6
+ ---
7
+
8
+ ## Step 1 — Inspect changes
9
+
10
+ Run the following to see what has changed:
11
+
12
+ ```bash
13
+ git diff --stat HEAD
14
+ git status --short
15
+ ```
16
+
17
+ For each changed or added file, read enough of its diff to understand **what
18
+ changed and why** — not just which lines moved.
19
+
20
+ ```bash
21
+ git diff HEAD -- <file>
22
+ ```
23
+
24
+ If files are staged but not committed, use:
25
+
26
+ ```bash
27
+ git diff --cached --stat
28
+ git diff --cached -- <file>
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Step 2 — Classify changes
34
+
35
+ Map each change to a Conventional Commits type:
36
+
37
+ | Type | Use when |
38
+ |---|---|
39
+ | `feat` | New behaviour or capability visible to a consumer |
40
+ | `fix` | Corrects incorrect behaviour |
41
+ | `perf` | Improves performance without changing behaviour |
42
+ | `refactor` | Internal restructuring; no behaviour change, no bug fix |
43
+ | `test` | Adds or fixes tests; no production code change |
44
+ | `docs` | Documentation only |
45
+ | `chore` | Tooling, config, dependencies, release machinery |
46
+ | `build` | Build system or external dependency changes |
47
+ | `ci` | CI configuration changes |
48
+
49
+ **Breaking change:** any change that removes or renames a public export, changes
50
+ a function signature, or alters a type in a way that requires consumer updates.
51
+ Mark with `!` after the type (e.g. `feat!`) and add a `BREAKING CHANGE:` footer.
52
+
53
+ **Scope:** the package or module affected — e.g. `prelude`, `boundary`, `agents`,
54
+ `react`, `dal`. Omit if the change is cross-cutting.
55
+
56
+ ---
57
+
58
+ ## Step 3 — Write the commit message
59
+
60
+ Produce a conventional commit message following this format exactly:
61
+
62
+ ```
63
+ <type>(<scope>): <imperative summary in sentence case, ≤ 72 chars>
64
+
65
+ <optional body — what changed and why, not how, wrapped at 72 chars>
66
+
67
+ <optional footers>
68
+ BREAKING CHANGE: <description if applicable>
69
+ Closes #<issue> (if applicable)
70
+ ```
71
+
72
+ Rules:
73
+ - Summary is imperative mood: "add", "fix", "remove" — not "added", "fixes"
74
+ - Summary does not end with a period
75
+ - Body explains the **why**, not the **what** (the diff is the what)
76
+ - One commit per logical change; if the diff contains multiple unrelated changes,
77
+ produce one message per change and say so
78
+
79
+ ---
80
+
81
+ ## Step 4 — Update CHANGELOG.md
82
+
83
+ Find or create the `## [Unreleased]` section at the top of `CHANGELOG.md`.
84
+ If `CHANGELOG.md` does not exist, create it with this header:
85
+
86
+ ```markdown
87
+ # Changelog
88
+
89
+ All notable changes to this project will be documented in this file.
90
+ This file is maintained automatically by [release-please](https://github.com/googleapis/release-please)
91
+ and supplemented during development via the `/trunk-changelog` prompt.
92
+
93
+ <!-- do not remove this comment — release-please uses it as an anchor -->
94
+ <!-- RELEASE-PLEASE-INSERTION-POINT -->
95
+
96
+ ## [Unreleased]
97
+ ```
98
+
99
+ Append the new entry under `## [Unreleased]`, grouped by type in this order:
100
+
101
+ ```markdown
102
+ ## [Unreleased]
103
+
104
+ ### Breaking changes
105
+ - `feat!(boundary)!: remove legacy `fold` export` — consumers must migrate to `map`/`flatMap`
106
+
107
+ ### Features
108
+ - `feat(prelude): add ReadonlyMap combinators` — `intoMap`, `assoc`, `dissoc`, `lookup`, `entriesOfMap`
109
+
110
+ ### Bug fixes
111
+ - `fix(agents): init.mjs fails with ReferenceError when run with --yes`
112
+
113
+ ### Performance
114
+ - ...
115
+
116
+ ### Refactoring
117
+ - ...
118
+
119
+ ### Tests
120
+ - ...
121
+
122
+ ### Documentation
123
+ - ...
124
+
125
+ ### Chores
126
+ - ...
127
+ ```
128
+
129
+ Only include sections that have entries. Do not add empty sections.
130
+
131
+ Each entry is a single line:
132
+ - Backtick-quoted commit summary
133
+ - Em dash
134
+ - One-sentence plain-English explanation of the user-visible impact
135
+
136
+ ---
137
+
138
+ ## Step 5 — Output
139
+
140
+ Print the proposed commit message in a code block so it can be copied directly
141
+ into the terminal or used with `git commit`:
142
+
143
+ ```
144
+ git commit -m "<type>(<scope>): <summary>" \
145
+ -m "<body paragraph if needed>"
146
+ ```
147
+
148
+ If there are multiple logical changes, list each message separately and recommend
149
+ committing them individually with `git add -p` to stage per-change.
150
+
151
+ ---
152
+
153
+ ## Rules
154
+
155
+ - Never invent changes that are not visible in the diff
156
+ - Never write a changelog entry for a change that has no user-visible impact
157
+ (internal renaming, comment edits) — use `chore` or `refactor` in the commit
158
+ message but omit from `## [Unreleased]`
159
+ - Do not modify any section of `CHANGELOG.md` other than `## [Unreleased]`
160
+ - release-please owns every versioned section (`## [1.2.3]`) — never touch those
@@ -0,0 +1,196 @@
1
+ ---
2
+ name: annotation-standard
3
+ description: >
4
+ Normative TSF++ annotation rules for all comments, JSDoc blocks, module
5
+ headers, code markers, and deviation records. Load when writing, reviewing,
6
+ or adding annotations to any TypeScript file: the "why not what" principle,
7
+ JSDoc body content (invariants, rejected alternatives, external contracts,
8
+ accepted imprecision, performance trade-offs), marker taxonomy with format,
9
+ DEVIATION pairing, and what must never be annotated.
10
+ ---
11
+
12
+ # TSF++ annotation standard
13
+
14
+ Full standard: `node_modules/@tsfpp/standard/spec/ANNOTATION_CODING_STANDARD.md`
15
+
16
+ ---
17
+
18
+ ## The single deciding question
19
+
20
+ > Does this tell the reader something they cannot confidently derive from the code and its types?
21
+
22
+ If no — do not add the comment. If yes — write it.
23
+
24
+ ---
25
+
26
+ ## What only a comment can tell you
27
+
28
+ | Category | Example |
29
+ |---|---|
30
+ | **Why this approach** over the natural alternative | Why linear scan instead of `Map` lookup |
31
+ | **Rejected alternatives** | What was considered and why it was ruled out |
32
+ | **Non-obvious invariants** | Preconditions the type cannot express |
33
+ | **Domain knowledge** | Business rules that live in the problem space |
34
+ | **External contracts** | Field names / values dictated by a third party |
35
+ | **Accepted imprecision** | Known limitations that are intentional |
36
+ | **Performance trade-offs** | Why a non-obvious implementation was chosen |
37
+ | **Temporal context** | Why a workaround exists, when to revisit it |
38
+
39
+ ---
40
+
41
+ ## JSDoc body: the why, not the what
42
+
43
+ The first sentence is the purpose. Everything after is the reasoning.
44
+
45
+ ```ts
46
+ /**
47
+ * Constructs a validated `UserId` from a raw string.
48
+ *
49
+ * Returns `None` if the input is empty. The empty case is excluded rather
50
+ * than mapped to an error because an empty ID indicates a caller bug, not
51
+ * a domain error — the type system prevents this at compile time in
52
+ * internal code; this guard exists for boundary inputs only.
53
+ *
54
+ * @param raw - The raw string to validate. Must be non-empty.
55
+ * @returns `Some(UserId)` if valid; `None` if empty.
56
+ *
57
+ * @example
58
+ * mkUserId('usr-00123') // => some(UserId('usr-00123'))
59
+ * mkUserId('') // => none
60
+ */
61
+ ```
62
+
63
+ `@param` describes the domain constraint, not the type. `@returns` describes the meaning, not the type. Both are already in the signature.
64
+
65
+ ---
66
+
67
+ ## Module header
68
+
69
+ Required on every file with public exports:
70
+
71
+ ```ts
72
+ /**
73
+ * @module user-account
74
+ *
75
+ * Domain model for user accounts. Provides the `UserAccount` sum type,
76
+ * its smart constructors, and the combinators for working with account
77
+ * state and identity.
78
+ *
79
+ * All functions are pure and total. Error cases are modelled via
80
+ * `Option<A>` (absent value) or `Result<T, E>` (fallible computation).
81
+ *
82
+ * @packageDocumentation
83
+ */
84
+ ```
85
+
86
+ First sentence: what the module provides. Second paragraph: key design constraints a consumer needs to know. Never describe the implementation.
87
+
88
+ ---
89
+
90
+ ## Inline comment patterns
91
+
92
+ ### Rejected alternative
93
+
94
+ ```ts
95
+ // NOTE(rob, 2026-05-18): Linear scan rather than `ReadonlyMap` lookup.
96
+ // The active session list is always ≤10 items per user; the allocation
97
+ // overhead of a map outweighs the O(1) lookup benefit at this scale.
98
+ const found = sessions.find(s => s.id === id)
99
+ ```
100
+
101
+ ### Non-obvious invariant
102
+
103
+ ```ts
104
+ // Invariant: `handlers` must be registered before this is called.
105
+ // The runtime guarantees registration order; do not call from a module
106
+ // initialiser that may run before the framework bootstraps.
107
+ ```
108
+
109
+ ### External contract
110
+
111
+ ```ts
112
+ // IMPORTANT: The field name `client_id` is specified by OAuth2 RFC 6749
113
+ // and must not be renamed despite the camelCase convention.
114
+ // DEVIATION(1.8): Field name required by external protocol.
115
+ ```
116
+
117
+ ### Accepted imprecision
118
+
119
+ ```ts
120
+ // NOTE(rob, 2026-05-18): Timestamp comparison has ≤1 s imprecision due
121
+ // to clock drift between service instances. Acceptable for audit logs;
122
+ // not acceptable for financial ordering.
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Code markers
128
+
129
+ Required format — no exceptions:
130
+
131
+ ```ts
132
+ // MARKER(author, YYYY-MM-DD[, TICKET]): description
133
+ ```
134
+
135
+ | Marker | Use when | Blocks merge? |
136
+ |---|---|---|
137
+ | `TODO` | Work required before next release | Soft — needs ticket |
138
+ | `FIXME` | Known bug the author is aware of | Yes |
139
+ | `HACK` | Temporary workaround with a deferred correct solution | Yes — needs ticket + revisit condition |
140
+ | `NOTE` | Context a reader needs to understand the code | No |
141
+ | `OPTIMIZE` | Correct but with a known performance concern at scale | No — needs scale threshold |
142
+ | `BUG` | Confirmed bug not yet in a ticket | Yes — convert to FIXME + ticket |
143
+ | `XXX` | Fragile or load-bearing — must not be casually changed | No — use sparingly |
144
+
145
+ ```ts
146
+ // TODO(rjansen, 2026-05-18, ARCH-44): Replace with Result-based validation
147
+ // once the boundary refactor lands in v2.0.
148
+ // HACK(rjansen, 2026-05-18, INFRA-12): Forced cast — third-party type
149
+ // definition is wrong. Fixed upstream in v4.x — remove after upgrade.
150
+ // NOTE(rjansen, 2026-05-18): Rate-limit window resets at midnight UTC,
151
+ // not relative to first request. Contractual requirement — do not change.
152
+ // XXX(rjansen, 2026-05-18): Initialisation order is load-bearing. The
153
+ // store must be hydrated before any handler is registered.
154
+ ```
155
+
156
+ Author = GitHub handle or initials. Never an AI. If unknown, use `unknown`.
157
+
158
+ ---
159
+
160
+ ## DEVIATION format
161
+
162
+ ```ts
163
+ // DEVIATION(N.M): <reason the violation could not be avoided>
164
+ ```
165
+
166
+ The justification explains why no alternative was feasible — not what the violation is.
167
+
168
+ Every `eslint-disable` must be paired:
169
+
170
+ ```ts
171
+ // DEVIATION(1.5): Legacy adapter — raw type narrowed to unknown immediately below.
172
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
+ const payload: any = deserialise(raw)
174
+ ```
175
+
176
+ The `as` in a smart constructor body:
177
+
178
+ ```ts
179
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- DEVIATION(1.6): smart-constructor body
180
+ return some(raw as UserId)
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Never annotate
186
+
187
+ | Forbidden | Example |
188
+ |---|---|
189
+ | Paraphrasing the code | `// Check if user is admin` above `if (user.role === 'admin')` |
190
+ | Restating the type | `// Returns a string` on `: string` |
191
+ | Commented-out code | `// const old = legacyParse(raw)` |
192
+ | Section dividers | `// ─────────────` with nothing meaningful |
193
+ | Stale comments | Any comment that no longer matches the code |
194
+ | AI attribution | `// Generated by Claude` |
195
+ | `@throws` on Result functions | Error is in the return type, not thrown |
196
+ | Apologetic comments | `// This is a bit hacky but...` — use `HACK` properly |
@@ -0,0 +1,205 @@
1
+ ---
2
+ name: config-standard
3
+ description: >
4
+ Normative TSF++ configuration management rules. Load when writing or reviewing
5
+ any code that loads environment variables, defines a Config type, calls
6
+ loadConfig, accesses process.env, implements a config loader, or writes
7
+ .env.example: loadConfig from @tsfpp/boundary, Config as typed readonly record,
8
+ Zod validation at the startup boundary, injection pattern, test factories.
9
+ ---
10
+
11
+ # TSF++ config standard
12
+
13
+ Full standard: `node_modules/@tsfpp/standard/spec/CONFIG_CODING_STANDARD.md`
14
+
15
+ ---
16
+
17
+ ## The pattern in one picture
18
+
19
+ ```
20
+ process.env (string | undefined)
21
+
22
+ loadConfig(schema, env) ← @tsfpp/boundary — validates all vars at once
23
+
24
+ Result<Config, ConfigError>
25
+ ↓ exit on Err at startup
26
+ Config (typed readonly record)
27
+ ↓ injected into every module that needs it
28
+ ```
29
+
30
+ `process.env` is only touched at the entry point. Everything downstream receives a typed `Config`.
31
+
32
+ ---
33
+
34
+ ## Imports
35
+
36
+ ```ts
37
+ // In the config loader
38
+ import { loadConfig, type ConfigError } from '@tsfpp/boundary'
39
+ import { isErr, ok, type Result } from '@tsfpp/prelude'
40
+
41
+ // In application modules — inject Config as a dependency, never import process.env
42
+ import { type Config } from '../shared/config'
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Config type — project-defined, not from a package
48
+
49
+ ```ts
50
+ // src/shared/config.ts
51
+ export type Config = {
52
+ readonly server: {
53
+ readonly port: number
54
+ readonly host: string
55
+ readonly logLevel: 'debug' | 'info' | 'warn' | 'error'
56
+ }
57
+ readonly database: {
58
+ readonly url: string
59
+ readonly poolMin: number
60
+ readonly poolMax: number
61
+ readonly queryTimeoutMs: number
62
+ }
63
+ readonly auth: {
64
+ readonly jwtSecret: string
65
+ readonly tokenTtlSeconds: number
66
+ }
67
+ readonly features: {
68
+ readonly maintenanceMode: boolean
69
+ }
70
+ }
71
+ ```
72
+
73
+ All fields are required. Optional config uses `Option<T>`, not `T | undefined`.
74
+
75
+ ---
76
+
77
+ ## Config loader
78
+
79
+ ```ts
80
+ // src/infrastructure/config-loader.ts
81
+ import { z } from 'zod'
82
+ import { loadConfig, type ConfigError } from '@tsfpp/boundary'
83
+ import { isErr, ok, type Result } from '@tsfpp/prelude'
84
+ import { type Config } from '../shared/config'
85
+
86
+ const schema = z.object({
87
+ PORT: z.coerce.number().int().min(1).max(65535).default(3000),
88
+ HOST: z.string().default('0.0.0.0'),
89
+ LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
90
+ DATABASE_URL: z.string().url(),
91
+ DATABASE_POOL_MIN: z.coerce.number().int().min(1).default(2),
92
+ DATABASE_POOL_MAX: z.coerce.number().int().min(1).default(10),
93
+ DATABASE_TIMEOUT_MS: z.coerce.number().int().min(100).default(5000),
94
+ JWT_SECRET: z.string().min(32),
95
+ TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).default(3600),
96
+ MAINTENANCE_MODE: z.coerce.boolean().default(false),
97
+ })
98
+
99
+ export const parseConfig = (
100
+ env: Record<string, string | undefined>
101
+ ): Result<Config, ConfigError> => {
102
+ const raw = loadConfig(schema, env)
103
+ if (isErr(raw)) return raw
104
+
105
+ const e = raw.value
106
+ return ok({
107
+ server: { port: e.PORT, host: e.HOST, logLevel: e.LOG_LEVEL },
108
+ database: { url: e.DATABASE_URL, poolMin: e.DATABASE_POOL_MIN,
109
+ poolMax: e.DATABASE_POOL_MAX, queryTimeoutMs: e.DATABASE_TIMEOUT_MS },
110
+ auth: { jwtSecret: e.JWT_SECRET, tokenTtlSeconds: e.TOKEN_TTL_SECONDS },
111
+ features: { maintenanceMode: e.MAINTENANCE_MODE },
112
+ })
113
+ }
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Entry point — fail at startup
119
+
120
+ ```ts
121
+ // src/main.ts
122
+ import { parseConfig } from './infrastructure/config-loader'
123
+
124
+ const configResult = parseConfig(process.env)
125
+ if (isErr(configResult)) {
126
+ console.error(configResult.error.summary)
127
+ process.exit(1)
128
+ }
129
+ const config = configResult.value
130
+ // wire up the application with config
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Injection — never import process.env in modules
136
+
137
+ ```ts
138
+ // Good — Config injected as part of Deps
139
+ type Deps = {
140
+ readonly db: Database
141
+ readonly logger: Logger
142
+ readonly config: Pick<Config, 'auth'>
143
+ }
144
+
145
+ // Bad — reads process.env inside a module
146
+ const ttl = parseInt(process.env.TOKEN_TTL_SECONDS ?? '3600', 10)
147
+
148
+ // Bad — imports a config singleton
149
+ import { config } from '../config'
150
+ ```
151
+
152
+ Use `Pick<Config, 'auth'>` to declare precisely which slice of config a module needs.
153
+
154
+ ---
155
+
156
+ ## Zod schema conventions
157
+
158
+ ```ts
159
+ // Required — no default; missing = startup failure
160
+ DATABASE_URL: z.string().url()
161
+ JWT_SECRET: z.string().min(32) // enforce minimum entropy for secrets
162
+
163
+ // Optional with sensible default
164
+ PORT: z.coerce.number().int().default(3000)
165
+ LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info')
166
+
167
+ // Boolean — coerce from 'true' / 'false' string
168
+ MAINTENANCE_MODE: z.coerce.boolean().default(false)
169
+ ```
170
+
171
+ All coercion (`string → number`, `string → boolean`) happens in the schema. Never parse in application code.
172
+
173
+ ---
174
+
175
+ ## Testing
176
+
177
+ ```ts
178
+ // Never mutate process.env in tests
179
+ // Bad
180
+ process.env.DATABASE_URL = 'postgres://localhost/test'
181
+
182
+ // Good — pass a plain record to the loader
183
+ const result = parseConfig({ DATABASE_URL: 'postgres://localhost/test', JWT_SECRET: 'a'.repeat(32), ... })
184
+
185
+ // Config factory for use-case and integration tests
186
+ // tests/helpers/config.factory.ts
187
+ export const makeConfig = (overrides: Partial<Config> = {}): Config => ({
188
+ server: { port: 3000, host: '127.0.0.1', logLevel: 'error', ...overrides.server },
189
+ database: { url: 'postgres://localhost/test', poolMin: 1, poolMax: 2,
190
+ queryTimeoutMs: 1000, ...overrides.database },
191
+ auth: { jwtSecret: 'a'.repeat(32), tokenTtlSeconds: 3600, ...overrides.auth },
192
+ features: { maintenanceMode: false, ...overrides.features },
193
+ ...overrides,
194
+ })
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Never
200
+
201
+ - Access `process.env` outside the config loader
202
+ - Import a config singleton in application modules
203
+ - Coerce types (parseInt, parseFloat) inside application code
204
+ - Log config values or `process.env` at any level
205
+ - Commit `.env` — only `.env.example` is committed
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: log-standard
3
+ description: >
4
+ Normative TSF++ logging rules. Load when writing or reviewing any code that
5
+ logs, uses the Logger port, implements a logger adapter, or calls tap/tapErr
6
+ for side effects: Logger port from @tsfpp/prelude, LogEntry field conventions,
7
+ log level semantics, structured message format, what never to log (PII,
8
+ secrets, stack traces), withRequestLog for HTTP, silentLogger for tests.
9
+ ---
10
+
11
+ # TSF++ log standard
12
+
13
+ Full standard: `node_modules/@tsfpp/standard/spec/LOG_CODING_STANDARD.md`
14
+
15
+ ---
16
+
17
+ ## Import
18
+
19
+ ```ts
20
+ import { type Logger, type LogEntry, type LogLevel } from '@tsfpp/prelude'
21
+ ```
22
+
23
+ Never import `pino`, `winston`, or `console` in core, use-case, or DAL code.
24
+
25
+ ---
26
+
27
+ ## Logger port
28
+
29
+ ```ts
30
+ // Infrastructure adapter — inject this; never the library directly
31
+ import pino from 'pino'
32
+ import { type Logger } from '@tsfpp/prelude'
33
+
34
+ export const logger: Logger = {
35
+ debug: (entry) => pinoInstance.debug(entry, entry.message),
36
+ info: (entry) => pinoInstance.info(entry, entry.message),
37
+ warn: (entry) => pinoInstance.warn(entry, entry.message),
38
+ error: (entry) => pinoInstance.error(entry, entry.message),
39
+ }
40
+
41
+ // Tests — always use silentLogger, never the production logger
42
+ export const silentLogger: Logger = {
43
+ debug: () => undefined,
44
+ info: () => undefined,
45
+ warn: () => undefined,
46
+ error: () => undefined,
47
+ }
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Log levels
53
+
54
+ | Level | Use when |
55
+ |---|---|
56
+ | `debug` | Diagnostic detail — disabled in production by default. Never log PII even at debug. |
57
+ | `info` | A significant, expected business event occurred. One entry per meaningful outcome. |
58
+ | `warn` | Unexpected but recoverable — retry triggered, rate limit approached, deprecated path called. |
59
+ | `error` | Failure requiring attention — operation failed, dependency unreachable, unhandled `Err` at boundary. |
60
+
61
+ `info` is for **business events**, not execution steps. Never log "calling repository", "repository returned".
62
+
63
+ ---
64
+
65
+ ## LogEntry field conventions
66
+
67
+ ```ts
68
+ // message — dot-separated event name, machine-readable
69
+ { message: 'user.created' }
70
+ { message: 'payment.charge.failed' }
71
+ { message: 'session.expired' }
72
+
73
+ // Always include traceId in request-scoped logs
74
+ { message: 'user.created', traceId: ctx.traceId, userId: user.id }
75
+
76
+ // Always include code on error-level logs
77
+ { message: 'db.query.failed', code: 'db_timeout', traceId }
78
+
79
+ // duration for operations with performance budgets (milliseconds)
80
+ { message: 'payment.charge.completed', duration: Date.now() - start, traceId }
81
+ ```
82
+
83
+ Flat structure only — no nested objects. Log aggregators cannot query nested fields.
84
+
85
+ ---
86
+
87
+ ## Logging in pipelines — tap / tapErr
88
+
89
+ Never break a `pipe` chain to log. Use `tap` / `tapErr`:
90
+
91
+ ```ts
92
+ // Good
93
+ pipe(
94
+ validateInput(input),
95
+ flatMap(createUser(deps.users)),
96
+ tap(user => deps.logger.info({ message: 'user.created', userId: user.id, traceId })),
97
+ tapErr(err => deps.logger.error({ message: 'user.create.failed', code: err.code, traceId })),
98
+ )
99
+
100
+ // Bad — breaks the pipeline
101
+ const result = await createUser(deps.users)(input)
102
+ if (isOk(result)) deps.logger.info(...) // separate from the pipeline
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Log `cause` before discarding it
108
+
109
+ `dependency` and `internal` `ApiError` variants carry a `cause` that is stripped by `apiErrorToResponse`. Log it first:
110
+
111
+ ```ts
112
+ if (isErr(result) && result.error.kind === 'dependency') {
113
+ deps.logger.error({
114
+ message: 'payment.gateway.unreachable',
115
+ code: 'dependency_error',
116
+ error: String(result.error.cause),
117
+ traceId: ctx.traceId,
118
+ })
119
+ return apiErrorToResponse(result.error, ctx)
120
+ }
121
+ ```
122
+
123
+ ---
124
+
125
+ ## HTTP request logging
126
+
127
+ Use `withRequestLog` from `@tsfpp/boundary` — never add manual logging to handler bodies.
128
+
129
+ ```ts
130
+ const handler = pipe(
131
+ createUserHandler(deps),
132
+ withIdempotency(store),
133
+ withRequestLog(logger, '/v1/users'), // always outermost; routeTemplate not resolved URL
134
+ )
135
+ ```
136
+
137
+ `routeTemplate` must be the parameterised path (`/v1/users/:id`), never the resolved URL.
138
+
139
+ ---
140
+
141
+ ## Never log
142
+
143
+ - PII — names, emails, phone numbers, addresses, national IDs
144
+ - Credentials — passwords, tokens, API keys, session IDs
145
+ - Full request or response bodies at `info` or above
146
+ - Stack traces in production — log `err.message`, not `err.stack`
147
+ - `process.env` or config values — they may contain secrets
148
+ - `console.*` anywhere except `main.ts` / `server.ts` startup boundary