@ontrails/core 1.0.0-beta.2 → 1.0.0-beta.21

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.
Files changed (245) hide show
  1. package/CHANGELOG.md +283 -5
  2. package/README.md +113 -33
  3. package/package.json +11 -1
  4. package/src/activation-provenance.ts +111 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-projection.ts +221 -0
  7. package/src/activation-source.ts +91 -0
  8. package/src/blob-ref.ts +51 -0
  9. package/src/branded.ts +1 -1
  10. package/src/compose-batch.ts +69 -0
  11. package/src/compose-schema.ts +36 -0
  12. package/src/context.ts +44 -10
  13. package/src/contour.ts +346 -0
  14. package/src/derive.ts +59 -18
  15. package/src/detours.ts +8 -0
  16. package/src/draft.ts +342 -0
  17. package/src/error-projection.ts +51 -0
  18. package/src/errors.ts +309 -48
  19. package/src/execute.ts +1577 -0
  20. package/src/fire.ts +1169 -0
  21. package/src/index.ts +477 -32
  22. package/src/internal/fork-ctx.ts +69 -0
  23. package/src/layer-projection.ts +193 -0
  24. package/src/layer.ts +43 -6
  25. package/src/observe.ts +361 -0
  26. package/src/path-security.ts +1 -1
  27. package/src/permits.ts +12 -0
  28. package/src/redaction/patterns.ts +6 -3
  29. package/src/resilience.ts +11 -11
  30. package/src/resource-config.ts +792 -0
  31. package/src/resource.ts +194 -0
  32. package/src/result.ts +36 -4
  33. package/src/run.ts +76 -0
  34. package/src/schedule-runtime.ts +689 -0
  35. package/src/schedule.ts +326 -0
  36. package/src/serialization.ts +119 -39
  37. package/src/signal-diagnostics.ts +633 -0
  38. package/src/signal-ref.ts +87 -0
  39. package/src/signal.ts +104 -0
  40. package/src/store/accessor-protocol.ts +56 -0
  41. package/src/store/index.ts +4 -0
  42. package/src/structured-examples.ts +248 -0
  43. package/src/surface-derivation.ts +91 -0
  44. package/src/surface-filter.ts +176 -0
  45. package/src/surface-versioning.ts +42 -0
  46. package/src/topo.ts +769 -57
  47. package/src/tracing.ts +345 -0
  48. package/src/trail.ts +1161 -29
  49. package/src/trails/derive-trail.ts +842 -0
  50. package/src/trails/index.ts +9 -0
  51. package/src/trails/ingest.ts +139 -0
  52. package/src/trails-db.ts +208 -0
  53. package/src/transport-error-map.ts +174 -0
  54. package/src/type-utils.ts +87 -0
  55. package/src/types.ts +254 -12
  56. package/src/validate-established-topo.ts +69 -0
  57. package/src/validate-topo.ts +442 -35
  58. package/src/validation.ts +152 -4
  59. package/src/version-marker.ts +716 -0
  60. package/src/version-resolution.ts +308 -0
  61. package/src/version-runtime.ts +120 -0
  62. package/src/webhook.ts +278 -0
  63. package/src/workspace.ts +1 -1
  64. package/src/zod-wrappers.ts +72 -0
  65. package/.turbo/turbo-build.log +0 -1
  66. package/.turbo/turbo-lint.log +0 -3
  67. package/.turbo/turbo-typecheck.log +0 -1
  68. package/dist/adapters.d.ts +0 -39
  69. package/dist/adapters.d.ts.map +0 -1
  70. package/dist/adapters.js +0 -2
  71. package/dist/adapters.js.map +0 -1
  72. package/dist/blob-ref.d.ts +0 -20
  73. package/dist/blob-ref.d.ts.map +0 -1
  74. package/dist/blob-ref.js +0 -22
  75. package/dist/blob-ref.js.map +0 -1
  76. package/dist/branded.d.ts +0 -36
  77. package/dist/branded.d.ts.map +0 -1
  78. package/dist/branded.js +0 -89
  79. package/dist/branded.js.map +0 -1
  80. package/dist/collections.d.ts +0 -31
  81. package/dist/collections.d.ts.map +0 -1
  82. package/dist/collections.js +0 -60
  83. package/dist/collections.js.map +0 -1
  84. package/dist/context.d.ts +0 -10
  85. package/dist/context.d.ts.map +0 -1
  86. package/dist/context.js +0 -15
  87. package/dist/context.js.map +0 -1
  88. package/dist/derive.d.ts +0 -33
  89. package/dist/derive.d.ts.map +0 -1
  90. package/dist/derive.js +0 -122
  91. package/dist/derive.js.map +0 -1
  92. package/dist/errors.d.ts +0 -83
  93. package/dist/errors.d.ts.map +0 -1
  94. package/dist/errors.js +0 -142
  95. package/dist/errors.js.map +0 -1
  96. package/dist/event.d.ts +0 -45
  97. package/dist/event.d.ts.map +0 -1
  98. package/dist/event.js +0 -17
  99. package/dist/event.js.map +0 -1
  100. package/dist/fetch.d.ts +0 -15
  101. package/dist/fetch.d.ts.map +0 -1
  102. package/dist/fetch.js +0 -102
  103. package/dist/fetch.js.map +0 -1
  104. package/dist/guards.d.ts +0 -17
  105. package/dist/guards.d.ts.map +0 -1
  106. package/dist/guards.js +0 -25
  107. package/dist/guards.js.map +0 -1
  108. package/dist/health.d.ts +0 -18
  109. package/dist/health.d.ts.map +0 -1
  110. package/dist/health.js +0 -5
  111. package/dist/health.js.map +0 -1
  112. package/dist/hike.d.ts +0 -36
  113. package/dist/hike.d.ts.map +0 -1
  114. package/dist/hike.js +0 -20
  115. package/dist/hike.js.map +0 -1
  116. package/dist/index.d.ts +0 -34
  117. package/dist/index.d.ts.map +0 -1
  118. package/dist/index.js +0 -38
  119. package/dist/index.js.map +0 -1
  120. package/dist/job.d.ts +0 -24
  121. package/dist/job.d.ts.map +0 -1
  122. package/dist/job.js +0 -17
  123. package/dist/job.js.map +0 -1
  124. package/dist/layer.d.ts +0 -17
  125. package/dist/layer.d.ts.map +0 -1
  126. package/dist/layer.js +0 -21
  127. package/dist/layer.js.map +0 -1
  128. package/dist/path-security.d.ts +0 -28
  129. package/dist/path-security.d.ts.map +0 -1
  130. package/dist/path-security.js +0 -63
  131. package/dist/path-security.js.map +0 -1
  132. package/dist/patterns/bulk.d.ts +0 -15
  133. package/dist/patterns/bulk.d.ts.map +0 -1
  134. package/dist/patterns/bulk.js +0 -14
  135. package/dist/patterns/bulk.js.map +0 -1
  136. package/dist/patterns/change.d.ts +0 -10
  137. package/dist/patterns/change.d.ts.map +0 -1
  138. package/dist/patterns/change.js +0 -10
  139. package/dist/patterns/change.js.map +0 -1
  140. package/dist/patterns/date-range.d.ts +0 -10
  141. package/dist/patterns/date-range.d.ts.map +0 -1
  142. package/dist/patterns/date-range.js +0 -10
  143. package/dist/patterns/date-range.js.map +0 -1
  144. package/dist/patterns/index.d.ts +0 -9
  145. package/dist/patterns/index.d.ts.map +0 -1
  146. package/dist/patterns/index.js +0 -9
  147. package/dist/patterns/index.js.map +0 -1
  148. package/dist/patterns/pagination.d.ts +0 -18
  149. package/dist/patterns/pagination.d.ts.map +0 -1
  150. package/dist/patterns/pagination.js +0 -18
  151. package/dist/patterns/pagination.js.map +0 -1
  152. package/dist/patterns/progress.d.ts +0 -11
  153. package/dist/patterns/progress.d.ts.map +0 -1
  154. package/dist/patterns/progress.js +0 -11
  155. package/dist/patterns/progress.js.map +0 -1
  156. package/dist/patterns/sorting.d.ts +0 -13
  157. package/dist/patterns/sorting.d.ts.map +0 -1
  158. package/dist/patterns/sorting.js +0 -10
  159. package/dist/patterns/sorting.js.map +0 -1
  160. package/dist/patterns/status.d.ts +0 -15
  161. package/dist/patterns/status.d.ts.map +0 -1
  162. package/dist/patterns/status.js +0 -9
  163. package/dist/patterns/status.js.map +0 -1
  164. package/dist/patterns/timestamps.d.ts +0 -10
  165. package/dist/patterns/timestamps.d.ts.map +0 -1
  166. package/dist/patterns/timestamps.js +0 -10
  167. package/dist/patterns/timestamps.js.map +0 -1
  168. package/dist/redaction/index.d.ts +0 -4
  169. package/dist/redaction/index.d.ts.map +0 -1
  170. package/dist/redaction/index.js +0 -3
  171. package/dist/redaction/index.js.map +0 -1
  172. package/dist/redaction/patterns.d.ts +0 -9
  173. package/dist/redaction/patterns.d.ts.map +0 -1
  174. package/dist/redaction/patterns.js +0 -39
  175. package/dist/redaction/patterns.js.map +0 -1
  176. package/dist/redaction/redactor.d.ts +0 -27
  177. package/dist/redaction/redactor.d.ts.map +0 -1
  178. package/dist/redaction/redactor.js +0 -89
  179. package/dist/redaction/redactor.js.map +0 -1
  180. package/dist/resilience.d.ts +0 -34
  181. package/dist/resilience.d.ts.map +0 -1
  182. package/dist/resilience.js +0 -164
  183. package/dist/resilience.js.map +0 -1
  184. package/dist/result.d.ts +0 -57
  185. package/dist/result.d.ts.map +0 -1
  186. package/dist/result.js +0 -145
  187. package/dist/result.js.map +0 -1
  188. package/dist/serialization.d.ts +0 -27
  189. package/dist/serialization.d.ts.map +0 -1
  190. package/dist/serialization.js +0 -115
  191. package/dist/serialization.js.map +0 -1
  192. package/dist/topo.d.ts +0 -18
  193. package/dist/topo.d.ts.map +0 -1
  194. package/dist/topo.js +0 -74
  195. package/dist/topo.js.map +0 -1
  196. package/dist/trail.d.ts +0 -83
  197. package/dist/trail.d.ts.map +0 -1
  198. package/dist/trail.js +0 -16
  199. package/dist/trail.js.map +0 -1
  200. package/dist/types.d.ts +0 -46
  201. package/dist/types.d.ts.map +0 -1
  202. package/dist/types.js +0 -2
  203. package/dist/types.js.map +0 -1
  204. package/dist/validate-topo.d.ts +0 -24
  205. package/dist/validate-topo.d.ts.map +0 -1
  206. package/dist/validate-topo.js +0 -108
  207. package/dist/validate-topo.js.map +0 -1
  208. package/dist/validation.d.ts +0 -27
  209. package/dist/validation.d.ts.map +0 -1
  210. package/dist/validation.js +0 -134
  211. package/dist/validation.js.map +0 -1
  212. package/dist/workspace.d.ts +0 -25
  213. package/dist/workspace.d.ts.map +0 -1
  214. package/dist/workspace.js +0 -57
  215. package/dist/workspace.js.map +0 -1
  216. package/src/__tests__/blob-ref.test.ts +0 -103
  217. package/src/__tests__/branded.test.ts +0 -148
  218. package/src/__tests__/collections.test.ts +0 -126
  219. package/src/__tests__/context.test.ts +0 -66
  220. package/src/__tests__/derive.test.ts +0 -159
  221. package/src/__tests__/errors.test.ts +0 -309
  222. package/src/__tests__/event.test.ts +0 -82
  223. package/src/__tests__/fetch.test.ts +0 -217
  224. package/src/__tests__/guards.test.ts +0 -102
  225. package/src/__tests__/hike.test.ts +0 -117
  226. package/src/__tests__/job.test.ts +0 -98
  227. package/src/__tests__/layer.test.ts +0 -224
  228. package/src/__tests__/path-security.test.ts +0 -114
  229. package/src/__tests__/patterns.test.ts +0 -273
  230. package/src/__tests__/redaction.test.ts +0 -244
  231. package/src/__tests__/resilience.test.ts +0 -246
  232. package/src/__tests__/result.test.ts +0 -155
  233. package/src/__tests__/serialization.test.ts +0 -236
  234. package/src/__tests__/topo.test.ts +0 -184
  235. package/src/__tests__/trail.test.ts +0 -179
  236. package/src/__tests__/validate-topo.test.ts +0 -201
  237. package/src/__tests__/validation.test.ts +0 -283
  238. package/src/__tests__/workspace.test.ts +0 -183
  239. package/src/adapters.ts +0 -68
  240. package/src/event.ts +0 -77
  241. package/src/health.ts +0 -23
  242. package/src/hike.ts +0 -77
  243. package/src/job.ts +0 -20
  244. package/tsconfig.json +0 -9
  245. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Shared, surface-agnostic helpers for projecting typed layer `input` schemas
3
+ * onto a surface's native idiom.
4
+ *
5
+ * Layer projection has two halves:
6
+ * 1. **Collection** — walk the trail's effective layers (topo → surface →
7
+ * trail) and keep only the ones that declare an `input` schema. These
8
+ * are the layers a surface needs to project.
9
+ * 2. **Naming/collision policy** — when a layer field's projected name
10
+ * collides with a name already claimed by the trail, by another layer,
11
+ * or by a surface-reserved name, the field is renamed using the
12
+ * deterministic `<layerName>-<originalField>` rule. The collision-detection
13
+ * logic itself is surface-agnostic; the *shape* of the projected name
14
+ * (kebab-case CLI flag, camelCase MCP parameter, HTTP request field)
15
+ * stays per-surface.
16
+ *
17
+ * This module owns the surface-agnostic half. CLI/MCP/HTTP each layer their
18
+ * own projection on top: see `@ontrails/cli/build`, `@ontrails/mcp/build`,
19
+ * and `@ontrails/http/build`.
20
+ *
21
+ * @see TRL-473 for the CLI projection that introduced this contract.
22
+ * @see TRL-474 for the MCP and HTTP projections that lifted these helpers.
23
+ */
24
+
25
+ import type { Layer } from './layer.js';
26
+ import type { Topo } from './topo.js';
27
+ import type { AnyTrail } from './trail.js';
28
+
29
+ export const LAYER_FIELD_RESERVED_NAMES: ReadonlySet<string> = new Set([
30
+ 'all',
31
+ 'devPermit',
32
+ 'dryRun',
33
+ 'input',
34
+ 'inputJson',
35
+ 'json',
36
+ 'jsonl',
37
+ 'output',
38
+ 'permit',
39
+ 'quiet',
40
+ 'token',
41
+ 'trailVersion',
42
+ 'trace',
43
+ 'watch',
44
+ ]);
45
+
46
+ const toKebabCase = (name: string): string =>
47
+ name.replaceAll(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
48
+
49
+ export const LAYER_FIELD_RESERVED_NAMES_KEBAB: ReadonlySet<string> = new Set(
50
+ [...LAYER_FIELD_RESERVED_NAMES].map(toKebabCase)
51
+ );
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Collection
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Source of a typed layer attached to a trail.
59
+ *
60
+ * Surfaces may want to know whether a layer came from topo-, surface-, or
61
+ * trail-scope (e.g. for descriptive errors). The collection helper preserves
62
+ * this information so callers don't have to recompute it.
63
+ */
64
+ export type AttachedLayerScope = 'topo' | 'surface' | 'trail';
65
+
66
+ export interface AttachedTypedLayer {
67
+ readonly layer: Layer;
68
+ readonly scope: AttachedLayerScope;
69
+ }
70
+
71
+ /**
72
+ * Collect every typed layer attached to a trail in the same composition order
73
+ * the executor uses (topo → surface → trail). Layers without an `input`
74
+ * schema are skipped — they have nothing to project onto a surface.
75
+ *
76
+ * @param graph - The topo carrying topo-scope layers.
77
+ * @param trail - The trail whose effective layers we are projecting.
78
+ * @param surfaceLayers - Layers attached at surface scope (`options.layers`
79
+ * on the surface builder).
80
+ */
81
+ export const collectAttachedTypedLayers = (
82
+ graph: Topo,
83
+ trail: AnyTrail,
84
+ surfaceLayers?: readonly Layer[] | undefined
85
+ ): readonly AttachedTypedLayer[] => {
86
+ const layers: AttachedTypedLayer[] = [];
87
+ for (const layer of graph.layers) {
88
+ if (layer.input !== undefined) {
89
+ layers.push({ layer, scope: 'topo' });
90
+ }
91
+ }
92
+ if (surfaceLayers !== undefined) {
93
+ for (const layer of surfaceLayers) {
94
+ if (layer.input !== undefined) {
95
+ layers.push({ layer, scope: 'surface' });
96
+ }
97
+ }
98
+ }
99
+ for (const layer of trail.layers) {
100
+ if (layer.input !== undefined) {
101
+ layers.push({ layer, scope: 'trail' });
102
+ }
103
+ }
104
+ return layers;
105
+ };
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Collision rename rule
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Reason a layer field was renamed during projection.
113
+ *
114
+ * Surfaces map this onto their own warning/error idiom. CLI emits a stderr
115
+ * warning, while MCP/HTTP rely on the projected schema as the source of truth.
116
+ */
117
+ export type LayerFieldRenameReason = 'reserved-name' | 'flag-collision';
118
+
119
+ /**
120
+ * Outcome of projecting a single layer field's name.
121
+ *
122
+ * `claimedName` is the name the surface will publish to consumers; it is the
123
+ * field name when no collision was detected, or `<layerName>-<fieldName>` (or
124
+ * an analogous transformed form, depending on the surface's convention) when
125
+ * a collision required a rename. `routingTarget` is the original field name
126
+ * the value should be assigned back to inside the layer's runtime input.
127
+ */
128
+ export interface LayerFieldProjection {
129
+ readonly claimedName: string;
130
+ readonly routingTarget: string;
131
+ readonly renamed: false;
132
+ }
133
+
134
+ export interface RenamedLayerFieldProjection {
135
+ readonly claimedName: string;
136
+ readonly routingTarget: string;
137
+ readonly renamed: true;
138
+ readonly originalName: string;
139
+ readonly reason: LayerFieldRenameReason;
140
+ }
141
+
142
+ export type ProjectedLayerField =
143
+ | LayerFieldProjection
144
+ | RenamedLayerFieldProjection;
145
+
146
+ /**
147
+ * Apply the deterministic collision rename rule to a single projected layer
148
+ * field name.
149
+ *
150
+ * @param layerName - The layer's logical name (used as the rename prefix).
151
+ * @param originalName - The layer field's name as authored on its schema.
152
+ * @param projectedName - The candidate name in the surface's native idiom
153
+ * (e.g. kebab-case for CLI, camelCase for MCP, request field for HTTP).
154
+ * @param renamedName - The fallback name applied when a collision is detected.
155
+ * Surfaces compute this with their own casing rule.
156
+ * @param claimedNames - Names already taken by the trail's input or by
157
+ * previous layer projections. Updated in place when a name is claimed.
158
+ * @param reservedNames - Framework-owned names that force a rename across
159
+ * surface projections.
160
+ */
161
+ export const projectLayerFieldName = (
162
+ _layerName: string,
163
+ originalName: string,
164
+ projectedName: string,
165
+ renamedName: string,
166
+ claimedNames: Set<string>,
167
+ reservedNames: ReadonlySet<string>
168
+ ): ProjectedLayerField => {
169
+ const collidesWithClaimed = claimedNames.has(projectedName);
170
+ const collidesWithReserved = reservedNames.has(projectedName);
171
+
172
+ if (!collidesWithClaimed && !collidesWithReserved) {
173
+ claimedNames.add(projectedName);
174
+ return {
175
+ claimedName: projectedName,
176
+ renamed: false,
177
+ routingTarget: originalName,
178
+ };
179
+ }
180
+
181
+ let claimedName = renamedName;
182
+ for (let suffix = 2; claimedNames.has(claimedName); suffix += 1) {
183
+ claimedName = `${renamedName}${suffix}`;
184
+ }
185
+ claimedNames.add(claimedName);
186
+ return {
187
+ claimedName,
188
+ originalName: projectedName,
189
+ reason: collidesWithReserved ? 'reserved-name' : 'flag-collision',
190
+ renamed: true,
191
+ routingTarget: originalName,
192
+ };
193
+ };
package/src/layer.ts CHANGED
@@ -1,18 +1,55 @@
1
- import type { Trail } from './trail.js';
1
+ import type { z } from 'zod';
2
+
3
+ import type { AnyTrail } from './trail.js';
2
4
  import type { Implementation } from './types.js';
3
5
 
4
6
  // ---------------------------------------------------------------------------
5
7
  // Layer interface
6
8
  // ---------------------------------------------------------------------------
7
9
 
8
- /** A composable middleware that wraps trail implementations */
10
+ /**
11
+ * A composable, named layer that wraps blazes.
12
+ *
13
+ * Layers attach at trail, surface, or topo scope and may declare an object
14
+ * `input` schema describing the configuration they need from the surrounding
15
+ * surface. Surface packages (CLI, MCP, HTTP) project this schema onto their
16
+ * native idioms — flags, tool parameters, query strings — alongside the
17
+ * trail's own input schema.
18
+ *
19
+ * @remarks
20
+ * The `input` schema is metadata for surface projection. It must be an object
21
+ * schema so every surface can project named fields consistently. It is
22
+ * optional; layers without an `input` schema behave as plain wrappers. The
23
+ * layer's `wrap` function is the runtime contract.
24
+ */
25
+ export type LayerInputSchema = z.ZodObject<z.ZodRawShape>;
26
+
9
27
  export interface Layer {
10
28
  readonly name: string;
11
29
  readonly description?: string | undefined;
12
30
 
13
- /** Wrap a trail's implementation, returning a new implementation */
31
+ /**
32
+ * Authored configuration the layer needs from the surrounding surface.
33
+ *
34
+ * Surface packages project this schema onto their native idioms (CLI flags,
35
+ * MCP tool parameters, HTTP query strings) so a layer's input fields appear
36
+ * alongside the trail's own input fields. Optional — layers that wrap purely
37
+ * by behavior, with no surface-visible inputs, may omit it.
38
+ *
39
+ * @see TRL-473 for CLI flag projection.
40
+ * @see TRL-474 for MCP and HTTP projection.
41
+ */
42
+ readonly input?: LayerInputSchema | undefined;
43
+
44
+ /**
45
+ * Wrap a trail's implementation, returning a new implementation.
46
+ *
47
+ * The trail is passed for metadata inspection (intent, schema, etc.).
48
+ * The implementation and return type are generic over the input/output
49
+ * types so layers remain type-safe when composed.
50
+ */
14
51
  wrap<I, O>(
15
- trail: Trail<I, O>,
52
+ trail: AnyTrail,
16
53
  implementation: Implementation<I, O>
17
54
  ): Implementation<I, O>;
18
55
  }
@@ -29,10 +66,10 @@ export interface Layer {
29
66
  */
30
67
  export const composeLayers = <I, O>(
31
68
  layers: readonly Layer[],
32
- trail: Trail<I, O>,
69
+ trail: AnyTrail,
33
70
  implementation: Implementation<I, O>
34
71
  ): Implementation<I, O> => {
35
- // Fold right so layers[0] is the outermost wrapper
72
+ // Fold right so layers[0] is the outermost wrapper.
36
73
  let result = implementation;
37
74
  for (let i = layers.length - 1; i >= 0; i -= 1) {
38
75
  const layer = layers[i];
package/src/observe.ts ADDED
@@ -0,0 +1,361 @@
1
+ import { ValidationError } from './errors.js';
2
+ import type { TraceSink } from './tracing.js';
3
+ import type { Layer } from './layer.js';
4
+ import { safeStringify } from './serialization.js';
5
+ import type { Logger, LogLevel, LogRecord, LogSink } from './types.js';
6
+
7
+ export interface ObserveConfig {
8
+ readonly log?: Logger | LogSink | undefined;
9
+ readonly trace?: TraceSink | undefined;
10
+ }
11
+
12
+ export interface ObserveCapabilities {
13
+ readonly log?: true | undefined;
14
+ readonly trace?: true | undefined;
15
+ }
16
+
17
+ interface ObserveCapable {
18
+ readonly observes?: ObserveCapabilities | undefined;
19
+ }
20
+
21
+ export type ObserveInput = Logger | LogSink | TraceSink | ObserveConfig;
22
+
23
+ export interface TopoOptions {
24
+ readonly observe?: ObserveInput | undefined;
25
+ /**
26
+ * Typed layers attached at topo scope.
27
+ *
28
+ * Layers declared here wrap every trail invoked through this topo, on every
29
+ * surface. The execution pipeline composes topo-scope layers outermost —
30
+ * around surface-scope and trail-scope layers — so the final order is
31
+ * `topo → surface → trail → blaze` (outermost-first).
32
+ */
33
+ readonly layers?: readonly Layer[] | undefined;
34
+ }
35
+
36
+ const OBSERVE_CONFIG_KEYS = new Set(['log', 'trace']);
37
+ export const OBSERVE_LOGGER_CONTEXT_KEY = '__trails_observe_logger';
38
+
39
+ /**
40
+ * Context extension key that carries metadata accumulated on the observe
41
+ * logger across rebindings (e.g. signal fan-out metadata such as `consumerId`
42
+ * and `signalId`). When `applyTopoObserveContext` rebinds the logger for a
43
+ * new trail, it merges this metadata into the freshly built observe logger
44
+ * so consumer log records retain provenance back to the triggering signal.
45
+ */
46
+ export const OBSERVE_LOGGER_METADATA_KEY = '__trails_observe_logger_metadata';
47
+
48
+ const isObject = (value: unknown): value is Record<string, unknown> =>
49
+ typeof value === 'object' && value !== null;
50
+
51
+ const hasFunction = (value: Record<string, unknown>, key: string): boolean =>
52
+ typeof value[key] === 'function';
53
+
54
+ export const isLogger = (value: unknown): value is Logger =>
55
+ isObject(value) &&
56
+ hasFunction(value, 'child') &&
57
+ hasFunction(value, 'debug') &&
58
+ hasFunction(value, 'error') &&
59
+ hasFunction(value, 'fatal') &&
60
+ hasFunction(value, 'info') &&
61
+ hasFunction(value, 'trace') &&
62
+ hasFunction(value, 'warn');
63
+
64
+ export const isLogSink = (value: unknown): value is LogSink =>
65
+ isObject(value) &&
66
+ typeof value['name'] === 'string' &&
67
+ hasFunction(value, 'write');
68
+
69
+ const readObserveCapabilities = (
70
+ value: unknown
71
+ ): ObserveCapabilities | undefined => {
72
+ if (!isObject(value)) {
73
+ return undefined;
74
+ }
75
+ const capabilities = (value as ObserveCapable).observes;
76
+ if (!isObject(capabilities)) {
77
+ return undefined;
78
+ }
79
+ const log = capabilities['log'] === true;
80
+ const trace = capabilities['trace'] === true;
81
+ if (!log && !trace) {
82
+ return undefined;
83
+ }
84
+ return Object.freeze({
85
+ ...(log ? { log: true as const } : {}),
86
+ ...(trace ? { trace: true as const } : {}),
87
+ });
88
+ };
89
+
90
+ export const isTraceSink = (value: unknown): value is TraceSink =>
91
+ isObject(value) && hasFunction(value, 'write');
92
+
93
+ const isObserveConfigShape = (value: unknown): value is ObserveConfig => {
94
+ if (!isObject(value)) {
95
+ return false;
96
+ }
97
+ const keys = Object.keys(value);
98
+ return (
99
+ keys.length > 0 &&
100
+ keys.every((key) => OBSERVE_CONFIG_KEYS.has(key)) &&
101
+ ('log' in value || 'trace' in value)
102
+ );
103
+ };
104
+
105
+ /**
106
+ * Type guard for the `ObserveInput` union. Returns `true` only for shapes
107
+ * that {@link normalizeObserve} will accept without throwing — i.e. a
108
+ * `Logger`, an explicit `ObserveConfig`, or a `TraceSink`. Capability-only
109
+ * payloads (`{ observes: { trace: true } }` without an accompanying
110
+ * `write` method) and bare `LogSink` shorthand are intentionally rejected:
111
+ * the former is metadata about a missing implementation, and the latter
112
+ * is ambiguous between a `LogSink` and a `TraceSink` and is rejected by
113
+ * `normalizeObserve` accordingly. Keeping the guard tighter than the
114
+ * runtime accepts would let callers narrow to `ObserveInput` and then
115
+ * see `normalizeObserve` throw at runtime.
116
+ */
117
+ export const isObserveInput = (
118
+ value: unknown
119
+ ): value is ObserveInput | undefined => {
120
+ if (value === undefined) {
121
+ return true;
122
+ }
123
+ if (isLogger(value)) {
124
+ return true;
125
+ }
126
+ if (isObserveConfigShape(value)) {
127
+ return true;
128
+ }
129
+ if (isTraceSink(value) && !isLogSink(value)) {
130
+ // A bare TraceSink (no `name`) is unambiguous — `normalizeObserve`
131
+ // accepts it via the `isTraceSink` fallthrough. A LogSink shape is
132
+ // ambiguous (matches both guards) and would be rejected by
133
+ // `normalizeObserve`, so the guard rejects it here too.
134
+ return true;
135
+ }
136
+ return false;
137
+ };
138
+
139
+ /**
140
+ * Returns true when `value` carries explicit observe capabilities via the
141
+ * `observes` discriminator. Used by the topo classifier to distinguish
142
+ * an `ObserveCapable` sink (clearly options) from a bare sink (ambiguous
143
+ * with a module export named `observe`).
144
+ */
145
+ export const hasObserveCapabilities = (value: unknown): boolean =>
146
+ readObserveCapabilities(value) !== undefined;
147
+
148
+ /**
149
+ * Returns true when `value` is shaped like the explicit `{ log?, trace? }`
150
+ * `ObserveConfig` payload. Exposed for the topo classifier so a config-style
151
+ * trailing argument is unambiguously classified as options.
152
+ */
153
+ export const isObserveConfig = (value: unknown): value is ObserveConfig =>
154
+ isObserveConfigShape(value);
155
+
156
+ const normalizeLogTarget = (
157
+ target: ObserveConfig['log']
158
+ ): ObserveConfig['log'] => {
159
+ if (target === undefined || isLogger(target) || isLogSink(target)) {
160
+ return target;
161
+ }
162
+ throw new ValidationError('topo observe.log must be a Logger or LogSink');
163
+ };
164
+
165
+ const normalizeTraceTarget = (
166
+ target: ObserveConfig['trace']
167
+ ): ObserveConfig['trace'] => {
168
+ if (target === undefined || isTraceSink(target)) {
169
+ return target;
170
+ }
171
+ throw new ValidationError('topo observe.trace must be a TraceSink');
172
+ };
173
+
174
+ /**
175
+ * Maps observe log levels to the `console` method that should receive the
176
+ * formatted record. `silent` is intentionally absent — records at that level
177
+ * are dropped before reaching `console`.
178
+ */
179
+ const DEFAULT_SINK_CONSOLE_METHOD: Record<
180
+ LogLevel,
181
+ 'debug' | 'error' | 'info' | 'warn' | undefined
182
+ > = {
183
+ debug: 'debug',
184
+ error: 'error',
185
+ fatal: 'error',
186
+ info: 'info',
187
+ silent: undefined,
188
+ trace: 'debug',
189
+ warn: 'warn',
190
+ };
191
+
192
+ const stringifyDefaultConsoleRecord = (record: LogRecord): string => {
193
+ const serialized = safeStringify({
194
+ category: record.category,
195
+ level: record.level,
196
+ message: record.message,
197
+ metadata: record.metadata,
198
+ timestamp: record.timestamp.toISOString(),
199
+ });
200
+ if (serialized.isOk()) {
201
+ return serialized.value;
202
+ }
203
+ return JSON.stringify({
204
+ category: record.category,
205
+ level: record.level,
206
+ message: record.message,
207
+ metadata: '[unserializable]',
208
+ timestamp: record.timestamp.toISOString(),
209
+ });
210
+ };
211
+
212
+ /**
213
+ * In-core mirror of `@ontrails/observe`'s `createConsoleSink` shape, kept
214
+ * minimal and private to avoid a reverse dependency from `@ontrails/core`
215
+ * onto `@ontrails/observe`. It mirrors the console level mapping in
216
+ * `packages/observe/src/sinks.ts:50` and emits each record as a single-line
217
+ * JSON object written to the matching `console.{debug|info|warn|error}` method.
218
+ *
219
+ * @remarks
220
+ * Used as the default `observe.log` target when `topo()` is called without an
221
+ * explicit `observe` option, so every app gets a non-null `ctx.logger` with
222
+ * structured stdout output and zero configuration. Apps that want richer
223
+ * formatting, file destinations, or custom sink behavior should pass an explicit
224
+ * `observe` option, which fully replaces this default.
225
+ */
226
+ const createDefaultConsoleSink = (): LogSink => ({
227
+ name: 'console',
228
+ write(record): void {
229
+ const method = DEFAULT_SINK_CONSOLE_METHOD[record.level];
230
+ if (method === undefined) {
231
+ return;
232
+ }
233
+ const payload = stringifyDefaultConsoleRecord(record);
234
+ // oxlint-disable-next-line trails-local/no-console-in-packages -- ADR 0041 mandates a default console logger in core; this is the single sanctioned console boundary, mirroring `@ontrails/observe`'s `createConsoleSink`.
235
+ console[method](payload);
236
+ },
237
+ });
238
+
239
+ /**
240
+ * The default observe configuration applied when `topo()` receives no
241
+ * `observe` option. Frozen so callers cannot mutate the shared default; the
242
+ * sink itself is shared across topos because it has no per-topo state.
243
+ */
244
+ const DEFAULT_OBSERVE_CONFIG: ObserveConfig = Object.freeze({
245
+ log: createDefaultConsoleSink(),
246
+ });
247
+
248
+ export const normalizeObserve = (
249
+ observe: ObserveInput | undefined
250
+ ): ObserveConfig | undefined => {
251
+ if (observe === undefined) {
252
+ // ADR 0041 promises a non-null `ctx.logger` with zero configuration.
253
+ // Returning the default config here lets the existing topo → adapter
254
+ // path project this log sink into `ctx.logger` without a second
255
+ // resolution point or a reverse dependency on `@ontrails/observe`.
256
+ return DEFAULT_OBSERVE_CONFIG;
257
+ }
258
+
259
+ const capabilities = readObserveCapabilities(observe);
260
+ if (capabilities !== undefined) {
261
+ const log =
262
+ capabilities.log === true
263
+ ? normalizeLogTarget(observe as Logger | LogSink)
264
+ : undefined;
265
+ const trace =
266
+ capabilities.trace === true
267
+ ? normalizeTraceTarget(observe as TraceSink)
268
+ : undefined;
269
+ return Object.freeze({
270
+ ...(log === undefined ? {} : { log }),
271
+ ...(trace === undefined ? {} : { trace }),
272
+ });
273
+ }
274
+
275
+ if (isLogger(observe)) {
276
+ return Object.freeze({ log: observe });
277
+ }
278
+
279
+ if (isObserveConfigShape(observe)) {
280
+ const log = normalizeLogTarget(observe.log);
281
+ const trace = normalizeTraceTarget(observe.trace);
282
+ if (log === undefined && trace === undefined) {
283
+ return undefined;
284
+ }
285
+ return Object.freeze({
286
+ ...(log === undefined ? {} : { log }),
287
+ ...(trace === undefined ? {} : { trace }),
288
+ });
289
+ }
290
+
291
+ if (isLogSink(observe)) {
292
+ throw new ValidationError(
293
+ 'topo observe shorthand is ambiguous for named sinks; use { log: sink } or { trace: sink }'
294
+ );
295
+ }
296
+
297
+ if (isTraceSink(observe)) {
298
+ return Object.freeze({ trace: observe });
299
+ }
300
+
301
+ throw new ValidationError(
302
+ 'topo observe must be a Logger, LogSink, TraceSink, or { log, trace } object'
303
+ );
304
+ };
305
+
306
+ const createLogSinkLogger = (
307
+ sink: LogSink,
308
+ category: string,
309
+ baseMetadata: Record<string, unknown>
310
+ ): Logger => {
311
+ const write = (
312
+ level: LogLevel,
313
+ message: string,
314
+ metadata?: Record<string, unknown>
315
+ ): void => {
316
+ sink.write({
317
+ category,
318
+ level,
319
+ message,
320
+ metadata: { ...baseMetadata, ...metadata },
321
+ timestamp: new Date(),
322
+ });
323
+ };
324
+
325
+ return {
326
+ child(metadata: Record<string, unknown>): Logger {
327
+ return createLogSinkLogger(sink, category, {
328
+ ...baseMetadata,
329
+ ...metadata,
330
+ });
331
+ },
332
+ debug(message, metadata): void {
333
+ write('debug', message, metadata);
334
+ },
335
+ error(message, metadata): void {
336
+ write('error', message, metadata);
337
+ },
338
+ fatal(message, metadata): void {
339
+ write('fatal', message, metadata);
340
+ },
341
+ info(message, metadata): void {
342
+ write('info', message, metadata);
343
+ },
344
+ name: category,
345
+ trace(message, metadata): void {
346
+ write('trace', message, metadata);
347
+ },
348
+ warn(message, metadata): void {
349
+ write('warn', message, metadata);
350
+ },
351
+ };
352
+ };
353
+
354
+ export const createObserveLogger = (
355
+ log: Logger | LogSink,
356
+ category: string,
357
+ metadata: Record<string, unknown>
358
+ ): Logger =>
359
+ isLogger(log)
360
+ ? log.child(metadata)
361
+ : createLogSinkLogger(log, category, metadata);
@@ -70,7 +70,7 @@ export const isPathSafe = (basePath: string, userPath: string): boolean => {
70
70
  * Joins multiple path segments, resolves them against `basePath`, and
71
71
  * validates the result stays within the base directory.
72
72
  */
73
- export const resolveSafePath = (
73
+ export const deriveSafePath = (
74
74
  basePath: string,
75
75
  ...segments: string[]
76
76
  ): Result<string, PermissionError> => {
package/src/permits.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Minimal permit shape available on TrailContext. */
4
+ export interface BasePermit {
5
+ readonly id: string;
6
+ readonly scopes: readonly string[];
7
+ }
8
+
9
+ export const basePermitSchema: z.ZodType<BasePermit> = z.object({
10
+ id: z.string(),
11
+ scopes: z.array(z.string()).readonly(),
12
+ });
@@ -16,11 +16,14 @@ export const DEFAULT_PATTERNS: RegExp[] = [
16
16
  // SSN: XXX-XX-XXXX
17
17
  /\b\d{3}-\d{2}-\d{4}\b/g,
18
18
 
19
- // Bearer tokens
20
- /Bearer\s+[A-Za-z0-9\-._~+/]+=*/g,
19
+ // Bearer tokens. Keep the threshold above short prose like "Bearer token".
20
+ /Bearer\s+[A-Za-z0-9\-._~+/=]{6,}/g,
21
21
 
22
22
  // Basic auth
23
- /Basic\s+[A-Za-z0-9+/]+=*/g,
23
+ /Basic\s+[A-Za-z0-9+/=]{8,}/g,
24
+
25
+ // Common key/value secrets embedded in strings
26
+ /\b(?:password|secret|token|api[_-]?key|cookie)\s*[:=]\s*[^\s,;]+/gi,
24
27
 
25
28
  // API keys: sk-*, pk_*, sk_* prefixed tokens
26
29
  /\b(?:sk-|pk_|sk_)[A-Za-z0-9_-]{8,}\b/g,