@synap-core/workspace-templates 0.10.0 → 0.12.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,351 @@
1
+ # Business Model workspace template
2
+ #
3
+ # WHY THIS TEMPLATE EXISTS (read before adding another strategy pack):
4
+ # The nine-component business-model interrogation (porteur, offer, market,
5
+ # money, means, and the conventions binding them) maps almost entirely onto
6
+ # vocabulary that ALREADY EXISTS in this corpus — `mission`/`vision`/`value`
7
+ # (foundation), `audience`/`pain-point`/`positioning`/`offer` (foundation,
8
+ # shared + pod-scoped), `client`/`partner`/`lead` (foundation shared roles),
9
+ # and the whole IBIS apparatus of `question`/`hypothesis`/`decision`/`source`
10
+ # (research-base + research-decision). Minting a parallel `business-model-*`
11
+ # vocabulary would have been a vocabulary fork — the exact failure
12
+ # `concept-guard.ts` was written to catch.
13
+ #
14
+ # So this pack adds only what is genuinely MISSING, which is three things:
15
+ # 1. `convention` — the binding external rule the model rests on (an
16
+ # eligibility threshold, a deontological duty, a contractual floor). Not
17
+ # a decision (nobody chose it) and not a finding (it is not evidence, it
18
+ # is a constraint). Nothing in the corpus expressed this.
19
+ # 2. `projection` — a quantified scenario with its assumptions attached, so
20
+ # a number can be challenged by challenging what it assumed.
21
+ # 3. `rests_on` / `refutes` — the two edges that make a business model a
22
+ # GRAPH instead of a document. `rests_on` is deliberately NOT `depends_on`
23
+ # or `blocks`: those are acyclic scheduling edges, and a business model is
24
+ # legitimately circular (the offer rests on the means, the means rest on
25
+ # the money, the money rests on the offer). `rests_on` carries support and
26
+ # staleness, not sequencing, so a cycle is information rather than a bug.
27
+ #
28
+ # The porteur and funder ROLES it needs are declared in foundation.yaml, the
29
+ # shared-role SSOT — see the note there.
30
+
31
+ meta:
32
+ slug: business-model
33
+ domain: strategy
34
+ name: Business Model
35
+ description: Interrogate a venture as a model that rests on things — components supported by evidence, bound by real conventions, and quantified by projections whose assumptions stay visible.
36
+ icon: layout-grid
37
+ color: "#0EA5E9"
38
+ tags: [strategy, business-model, grp, ibis, viability]
39
+ isPublic: true
40
+
41
+ workspace:
42
+ name: Foundation
43
+ description: Adds business-model interrogation to the strategic substrate — what the model rests on, which conventions bind it, and which numbers assume what.
44
+ subtype: foundation
45
+
46
+ # ONE compose base only — `validate.ts` requires every compose dependency to
47
+ # equal `workspace.subtype`, which is correct: an overlay is layered into one
48
+ # workspace, and a thing cannot be layered into two. The business model belongs
49
+ # in Foundation, because its components ARE the strategic DNA. The evidence
50
+ # apparatus it borrows (hypothesis, source, finding) lives in the Research
51
+ # workspace and is reached by RELATION, not by merging two lenses into one.
52
+ dependencies:
53
+ - slug: foundation
54
+ relation: compose
55
+ reason: The model's components ARE the foundation vocabulary — mission, audience, pain-point, positioning, offer, and the client/partner/porteur/funder roles. This pack interrogates them; it does not restate them.
56
+
57
+ profiles:
58
+ # ── Re-declared from dependencies, verbatim-compatible ─────────────────────
59
+ # Re-declaring a composed profile is how a pack states what it operates on;
60
+ # the apply layer resolves shared slugs to the ONE pod-wide row. Shapes here
61
+ # are strict subsets of the owner's shape, so no overlay conflict arises.
62
+ - slug: question
63
+ displayName: Question
64
+ icon: help-circle
65
+ profileKind: kind
66
+ properties:
67
+ - slug: questionStatus
68
+ label: Status
69
+ valueType: string
70
+ inputType: select
71
+ enumValues: [open, exploring, answered, abandoned]
72
+ - slug: decision
73
+ displayName: Decision
74
+ icon: gavel
75
+ profileKind: kind
76
+ properties:
77
+ - slug: decisionStatus
78
+ label: Status
79
+ valueType: string
80
+ inputType: select
81
+ enumValues: [proposed, accepted, superseded, rejected]
82
+
83
+ # ── What this pack actually adds ───────────────────────────────────────────
84
+ - slug: convention
85
+ displayName: Convention
86
+ icon: scale
87
+ color: rose
88
+ description: A binding rule the model must satisfy and did not choose — an eligibility threshold, a professional duty, a contractual floor, a platform policy. Distinct from a decision (nobody decided it) and from a finding (it constrains rather than informs).
89
+ profileKind: kind
90
+ properties:
91
+ - slug: rule
92
+ label: The Rule
93
+ valueType: string
94
+ inputType: textarea
95
+ description: State the constraint in the terms the issuer uses, not in a paraphrase. Paraphrase is where eligibility collisions hide.
96
+ - slug: issuer
97
+ label: Issued By
98
+ valueType: string
99
+ inputType: text
100
+ description: Who imposes it — a regulator, a funder, an ordre professionnel, a platform, a counterparty.
101
+ - slug: conventionType
102
+ label: Type
103
+ valueType: string
104
+ inputType: select
105
+ enumValues: [regulatory, deontological, eligibility, contractual, platform, fiscal, other]
106
+ - slug: bindingStatus
107
+ label: Binding Status
108
+ valueType: string
109
+ inputType: select
110
+ enumValues: [verified, assumed, contested, expired]
111
+ description: "`assumed` is the dangerous default — it means nobody has read the source text yet."
112
+ - slug: sourceReference
113
+ label: Source Reference
114
+ valueType: string
115
+ inputType: text
116
+ description: The cahier des charges, article, or guide this comes from. A convention with no reference is an assumption wearing a rule's clothes.
117
+ - slug: expiresAt
118
+ label: Expires / Review By
119
+ valueType: date
120
+ inputType: date
121
+ - slug: consequenceIfBreached
122
+ label: Consequence If Breached
123
+ valueType: string
124
+ inputType: textarea
125
+
126
+ - slug: projection
127
+ displayName: Projection
128
+ icon: trending-up
129
+ color: violet
130
+ description: A quantified scenario with its assumptions attached. The number is never the artifact — the assumptions are, because that is the only part anyone can argue with.
131
+ profileKind: kind
132
+ properties:
133
+ - slug: scenario
134
+ label: Scenario
135
+ valueType: string
136
+ inputType: select
137
+ enumValues: [pessimistic, base, optimistic]
138
+ - slug: horizon
139
+ label: Horizon
140
+ valueType: string
141
+ inputType: select
142
+ enumValues: [month, quarter, year, three-year]
143
+ - slug: assumptions
144
+ label: Assumptions
145
+ valueType: string
146
+ inputType: textarea
147
+ description: Every input this number needs to be true. When one changes, whatever rests on this projection goes stale.
148
+ - slug: revenueEstimate
149
+ label: Revenue Estimate
150
+ valueType: number
151
+ inputType: number
152
+ - slug: costEstimate
153
+ label: Cost Estimate
154
+ valueType: number
155
+ inputType: number
156
+ - slug: breakEvenUnits
157
+ label: Break-Even Units
158
+ valueType: number
159
+ inputType: number
160
+ - slug: confidence
161
+ label: Confidence
162
+ valueType: string
163
+ inputType: select
164
+ enumValues: [low, medium, high]
165
+
166
+ # Relation TYPE vocabulary. Declared here (not as bare entityLinks) because
167
+ # these two carry description + inverse labels that the entity-link path drops.
168
+ relationDefs:
169
+ - slug: rests_on
170
+ displayName: Rests On
171
+ description: "The source depends on the target for its validity — not for its scheduling. Cycles are LEGAL and meaningful here: a business model is mutually supporting by nature, and forcing it into an acyclic dependency graph destroys the information. When the target changes, everything resting on it is stale until re-checked."
172
+ isDirectional: true
173
+ uiHints:
174
+ category: reference
175
+ inverseLabel: Supports
176
+ - slug: refutes
177
+ displayName: Refutes
178
+ description: The source is evidence that the target is false. Kept as a first-class edge so a refuted component stays in the graph with its refutation attached, instead of being quietly deleted — the reason a road was not taken is worth more than the road.
179
+ isDirectional: true
180
+ uiHints:
181
+ category: reference
182
+ inverseLabel: Refuted By
183
+
184
+ entityLinks:
185
+ - sourceProfileSlug: offer
186
+ targetProfileSlug: convention
187
+ type: rests_on
188
+ label: Rests on
189
+ - sourceProfileSlug: offer
190
+ targetProfileSlug: audience
191
+ type: rests_on
192
+ label: Rests on
193
+ - sourceProfileSlug: projection
194
+ targetProfileSlug: offer
195
+ type: rests_on
196
+ label: Rests on
197
+ - sourceProfileSlug: decision
198
+ targetProfileSlug: convention
199
+ type: rests_on
200
+ label: Rests on
201
+ - sourceProfileSlug: decision
202
+ targetProfileSlug: projection
203
+ type: rests_on
204
+ label: Rests on
205
+ - sourceProfileSlug: convention
206
+ targetProfileSlug: offer
207
+ type: refutes
208
+ label: Refutes
209
+ - sourceProfileSlug: convention
210
+ targetProfileSlug: audience
211
+ type: refutes
212
+ label: Refutes
213
+ - sourceProfileSlug: convention
214
+ targetProfileSlug: question
215
+ type: rests_on
216
+ label: Rests on
217
+
218
+ views:
219
+ - name: Conventions
220
+ slug: business-model-conventions
221
+ type: table
222
+ scopeProfileSlug: convention
223
+ description: Every rule the model must satisfy, who imposes it, and whether anyone has actually verified it.
224
+ defaultView: true
225
+ config:
226
+ columns: [title, conventionType, issuer, bindingStatus, expiresAt]
227
+ - name: Unverified Conventions
228
+ slug: business-model-assumed-conventions
229
+ type: list
230
+ scopeProfileSlug: convention
231
+ description: Conventions still marked assumed or contested — the model rests on these and nobody has read the source text.
232
+ config:
233
+ filters:
234
+ - field: bindingStatus
235
+ operator: in
236
+ value: [assumed, contested]
237
+ - name: Projections
238
+ slug: business-model-projections
239
+ type: table
240
+ scopeProfileSlug: projection
241
+ description: Scenarios with their assumptions in the open, so a number can be argued with.
242
+ config:
243
+ columns: [title, scenario, horizon, revenueEstimate, costEstimate, confidence]
244
+ - name: Open Interrogation
245
+ slug: business-model-open-questions
246
+ type: kanban
247
+ scopeProfileSlug: question
248
+ groupBy: questionStatus
249
+ description: The nine component questions and everything they spawned, by status.
250
+
251
+ # ── Seeds: the interrogation, not fake data ────────────────────────────────
252
+ # A strategy template that seeds example companies teaches nothing and gets
253
+ # deleted. What is actually reusable is the QUESTION SET — the workspace should
254
+ # arrive with the model's nine interrogations already open, so the first session
255
+ # has somewhere to start and the kanban is not an empty box.
256
+ seedEntities:
257
+ - profileSlug: question
258
+ title: Who is the porteur, and what does the model rest on them for?
259
+ properties:
260
+ questionStatus: open
261
+ content: Name the person, then name the specific competence, network, capital, or legitimacy the model cannot survive without. If the answer is generic, the model has no porteur — it has a job posting.
262
+ - profileSlug: question
263
+ title: What does the porteur's situation forbid?
264
+ properties:
265
+ questionStatus: open
266
+ content: Runway, hours available, geography, non-compete, dependants, health. Constraints discovered in month four were knowable in week one.
267
+ - profileSlug: question
268
+ title: What is the offer, stated as the problem it ends?
269
+ properties:
270
+ questionStatus: open
271
+ content: Not a description of what is built — a statement of the situation a buyer is in before, and after. If the offer can only be described by its features, the buyer has not been found yet.
272
+ - profileSlug: question
273
+ title: Who exactly buys it, and who merely benefits from it?
274
+ properties:
275
+ questionStatus: open
276
+ content: Link to `audience` entities. Payer, user, and beneficiary are frequently three different parties, and a model that conflates them forecasts revenue from people who never pay.
277
+ - profileSlug: question
278
+ title: Which conventions bind this model, and which are still assumed?
279
+ properties:
280
+ questionStatus: open
281
+ content: Eligibility thresholds, professional duties, platform policies, fiscal regimes. Every convention starts `assumed`; the work is reading the source text and moving it to `verified`. An eligibility collision — where the segment with the most pull sits outside the scheme that would fund it — only becomes visible once the numbers are pulled.
282
+ - profileSlug: question
283
+ title: What does one unit of delivery actually cost to produce?
284
+ properties:
285
+ questionStatus: open
286
+ content: Time, tooling, subcontracting, the unbilled parts. Link the answer to a `projection` so the assumptions stay attached to the number.
287
+ - profileSlug: question
288
+ title: Where does the money come from before revenue does?
289
+ properties:
290
+ questionStatus: open
291
+ content: Attach `funder` roles to the companies or schemes involved, with their eligibility conditions. Subsidy is a credential and a discount; it is rarely a pipeline.
292
+ - profileSlug: question
293
+ title: What means — people, tools, licences, premises — must exist first?
294
+ properties:
295
+ questionStatus: open
296
+ content: The means rest on the money and the money rests on the offer. That circularity is the model, not a planning error; record it with `rests_on` and stop trying to linearise it.
297
+ - profileSlug: question
298
+ title: What observation would refute this model, and by when?
299
+ properties:
300
+ questionStatus: open
301
+ content: Set the falsifiable test now, while it can still be set honestly. A model with no refuting observation is a belief with a spreadsheet attached.
302
+
303
+ # The circularity, declared. These edges deliberately form cycles — that is the
304
+ # point of `rests_on`, and the reason this pack does not use `depends_on`.
305
+ suggestedRelations:
306
+ - sourceRef: What is the offer, stated as the problem it ends?
307
+ targetRef: Who exactly buys it, and who merely benefits from it?
308
+ type: rests_on
309
+ - sourceRef: What is the offer, stated as the problem it ends?
310
+ targetRef: What means — people, tools, licences, premises — must exist first?
311
+ type: rests_on
312
+ - sourceRef: What means — people, tools, licences, premises — must exist first?
313
+ targetRef: Where does the money come from before revenue does?
314
+ type: rests_on
315
+ - sourceRef: Where does the money come from before revenue does?
316
+ targetRef: What is the offer, stated as the problem it ends?
317
+ type: rests_on
318
+ - sourceRef: Where does the money come from before revenue does?
319
+ targetRef: Which conventions bind this model, and which are still assumed?
320
+ type: rests_on
321
+ - sourceRef: Who exactly buys it, and who merely benefits from it?
322
+ targetRef: Which conventions bind this model, and which are still assumed?
323
+ type: rests_on
324
+ - sourceRef: What does one unit of delivery actually cost to produce?
325
+ targetRef: What does the porteur's situation forbid?
326
+ type: rests_on
327
+ - sourceRef: What is the offer, stated as the problem it ends?
328
+ targetRef: Who is the porteur, and what does the model rest on them for?
329
+ type: rests_on
330
+
331
+ playbooks:
332
+ - name: Business Model Interrogation
333
+ description: Walk the nine components as questions, resolve each into a decision backed by evidence, and record the conventions and projections the model rests on — including the cycles.
334
+ goalTemplate: "Interrogate the business model for {venture} and record what it rests on. Steps: (1) Walk each of the nine seeded `question` entities in this workspace; where one is already answered, mark it and move on. (2) Resolve each into a `decision`, keeping the options that lost attached via `refutes` rather than deleting them. Where the evidence itself needs gathering, do that in the Research workspace and link back — do not restate sources here. (3) Every external rule discovered becomes a `convention` with its `issuer` and `sourceReference`; leave `bindingStatus` at `assumed` until the source text has actually been read, then `verified`. (4) Every number becomes a `projection` with its `assumptions` filled in — never a bare figure. (5) Wire the graph with `rests_on` — offer on audience, projection on offer, decision on convention. Do NOT try to make the graph acyclic: if the offer rests on the means and the means rest on the money and the money rests on the offer, record all three. (6) Attach `porteur` and `funder` roles to the real people and organisations instead of creating new entities for them. (7) Close by listing which conventions are still `assumed` and which projections carry `low` confidence — that list is the actual state of the model."
335
+ icon: layout-grid
336
+
337
+ actionPlacements:
338
+ - profileSlug: question
339
+ surface: entity-detail
340
+ kind: playbook
341
+ ref: Business Model Interrogation
342
+ label: Interrogate as model component
343
+ - profileSlug: convention
344
+ surface: entity-detail
345
+ kind: playbook
346
+ ref: Business Model Interrogation
347
+ label: Re-check what rests on this
348
+
349
+ integrations:
350
+ - templateKey: research-methods
351
+ - templateKey: exa
package/src/catalog.ts CHANGED
@@ -33,6 +33,20 @@ import type { WorkspaceYaml } from "./types.js";
33
33
  // Inputs
34
34
  // ============================================================================
35
35
 
36
+ /**
37
+ * A lightweight, QUERY-TIME summary of one sub-object a package carries (a cell
38
+ * today; the shape generalizes to views/automations without change). Rides the
39
+ * catalog LIST projection so a surface can match/surface a sub-item WITHOUT the
40
+ * full `definition` — it carries `key + name` only, never the sub-object's code.
41
+ * `kind` is DERIVED (in the CP SELECT, from which sub-array the row came from),
42
+ * never a stored taxonomy.
43
+ */
44
+ export interface PackageItemSummary {
45
+ kind: string;
46
+ key: string;
47
+ name: string;
48
+ }
49
+
36
50
  /**
37
51
  * A bundled template — the canonical `WorkspaceYaml` this package ships. Callers
38
52
  * pass `listPublicTemplates()` (18) or `listWorkspaceTemplates()` (21); the door
@@ -69,6 +83,20 @@ export interface RemoteEntry {
69
83
  * never "unknown-so-hide".
70
84
  */
71
85
  isPrivate?: boolean;
86
+ /**
87
+ * The minimum subscription tier required to INSTALL this package (`solo` | `pro`
88
+ * | `team` | `enterprise`); `null`/absent ⇒ ungated. DISPLAY only here — the real
89
+ * install floor is the pod's `assertPackageTierAccess`. Kept a bare `string` so
90
+ * this pure/shared door stays enum-agnostic (like `category`).
91
+ */
92
+ requiredTier?: string | null;
93
+ /**
94
+ * Lightweight computed summary of the row's sub-objects (cells), carried on the
95
+ * LIST projection so a surface can surface a matching sub-item without the full
96
+ * `definition`. `undefined` on sources that don't compute it (bundled) — treated
97
+ * as "no known sub-items". See `PackageItemSummary`.
98
+ */
99
+ items?: PackageItemSummary[];
72
100
  /**
73
101
  * The CP `definition` jsonb — opaque install payload. The door reads only
74
102
  * `definition.sourcePackage.version` (the winner signal). Absent on browse rows
@@ -128,6 +156,13 @@ export interface CpPackageRow {
128
156
  icon?: string | null;
129
157
  /** Present only on `/mine` rows. `false` ⇒ private. Browse rows omit it. */
130
158
  isPublic?: boolean;
159
+ /** The package's `required_tier` gate (`solo`|`pro`|…); `null`/absent ⇒ ungated. */
160
+ requiredTier?: string | null;
161
+ /**
162
+ * Computed sub-object summary from `GET /api/packages` / `/mine` (query-time,
163
+ * not a stored column). Carries `key + name` per cell; never the cell code.
164
+ */
165
+ items?: PackageItemSummary[];
131
166
  }
132
167
 
133
168
  /**
@@ -154,6 +189,8 @@ export function rowToRemoteEntry(
154
189
  tags: row.tags ?? [],
155
190
  icon: row.icon ?? null,
156
191
  isPrivate,
192
+ requiredTier: row.requiredTier ?? null,
193
+ items: row.items,
157
194
  // Both list routes strip `definition`; absence ⇒ bundle wins a slug conflict,
158
195
  // and the install path fetches the full definition per-slug when needed.
159
196
  };
@@ -175,6 +212,18 @@ interface CatalogEntryBase {
175
212
  icon?: string | null;
176
213
  /** `true` ⇒ not publicly listed (internal bundled template, or private CP row). */
177
214
  isPrivate: boolean;
215
+ /**
216
+ * Minimum install tier (`solo`|`pro`|…); `null` ⇒ ungated. Bundled templates are
217
+ * always `null` (they ship in-client, never gated). DISPLAY only — a surface
218
+ * shows a "requires <tier>" badge; the real floor is the pod's install check.
219
+ */
220
+ requiredTier?: string | null;
221
+ /**
222
+ * The entry's sub-object summary (cells). Always present (defaults to `[]`) so a
223
+ * consumer can match sub-items uniformly. Bundled workspace templates carry no
224
+ * cells → `[]` (honest); a remote row carries its CP-computed `items`.
225
+ */
226
+ items: PackageItemSummary[];
178
227
  }
179
228
 
180
229
  /** A catalog entry backed by a bundled template. Install via `toWorkspaceDefinition()` / `toPackageDefinition()` on `template`. */
@@ -256,6 +305,10 @@ function bundledToEntry(tpl: WorkspaceYaml): BundledCatalogEntry {
256
305
  tags: tpl.meta.tags ?? [],
257
306
  icon: tpl.meta.icon,
258
307
  isPrivate: tpl.meta.isPublic === false,
308
+ // Bundled templates ship in-client and are never tier-gated.
309
+ requiredTier: null,
310
+ // Bundled workspace templates carry no cells — be honest, not `undefined`.
311
+ items: [],
259
312
  template: tpl,
260
313
  };
261
314
  }
@@ -271,6 +324,8 @@ function remoteToEntry(row: RemoteEntry): RemoteCatalogEntry {
271
324
  tags: row.tags ?? [],
272
325
  icon: row.icon ?? null,
273
326
  isPrivate: row.isPrivate ?? false,
327
+ requiredTier: row.requiredTier ?? null,
328
+ items: row.items ?? [],
274
329
  remote: row,
275
330
  };
276
331
  }
@@ -0,0 +1,102 @@
1
+ # France civic open-data research overlay — STUB (enhance later)
2
+ #
3
+ # Thin overlay that composes onto research-base for investigating French public
4
+ # data (open data / Licence Ouverte). NOT a government product, NOT a marketplace
5
+ # civic-tech app. Private workspace template only.
6
+ #
7
+ # Data access: consume official APIs only — data.gouv.fr, api.gouv.fr, PISTE.
8
+ # Never scrape HTML. Do not invent a public pod host or resource_shares.
9
+ # No automations that fetch the web in this stub.
10
+
11
+ meta:
12
+ slug: civic-france
13
+ domain: knowledge
14
+ name: France Civic Research
15
+ description: Overlay for investigating French public data (open data / Licence Ouverte). Composes onto Research. Not a government product — a private research stub.
16
+ icon: landmark
17
+ color: "#1D4ED8"
18
+ tags: [research, civic, france, open-data, licence-ouverte]
19
+ isPublic: false
20
+
21
+ workspace:
22
+ name: Research
23
+ description: Adds a French civic open-data lens onto the Research substrate — track public datasets consulted during investigations.
24
+ subtype: research-base
25
+
26
+ # Layer additively onto the research-base workspace (no second workspace).
27
+ # Visibility and capabilities OMITTED so the base Research workspace keeps its own.
28
+ dependencies:
29
+ - slug: research-base
30
+ relation: compose
31
+ reason: France civic research is a research method — it needs the Research substrate (question / research / source) to work over.
32
+
33
+ profiles:
34
+ # Slugs this overlay reuses from research-base — declared for the per-file
35
+ # validator (base owns their full schema; the applier skips already-existing
36
+ # properties). Mirror research-decision's reuse pattern.
37
+ - slug: question
38
+ displayName: Question
39
+ icon: help-circle
40
+ color: blue
41
+ description: An open question driving a civic-data investigation.
42
+ properties:
43
+ - slug: questionStatus
44
+ label: Status
45
+ valueType: string
46
+ inputType: select
47
+ enumValues: [open, exploring, answered, abandoned]
48
+ constraints: { defaultValue: open }
49
+
50
+ - slug: research
51
+ displayName: Research
52
+ icon: microscope
53
+ color: violet
54
+ description: An investigation into French public data — sources, findings, conclusion.
55
+ properties:
56
+ - slug: researchStatus
57
+ label: Status
58
+ valueType: string
59
+ inputType: select
60
+ enumValues: [ongoing, concluded, abandoned]
61
+ constraints: { defaultValue: ongoing }
62
+
63
+ - slug: source
64
+ displayName: Source
65
+ icon: file-text
66
+ color: cyan
67
+ description: Anything consulted — API payload, dataset page, decree, report.
68
+ properties:
69
+ - slug: url
70
+ label: URL
71
+ valueType: string
72
+ inputType: url
73
+
74
+ # Workspace-owned catalog entry for a French public dataset (thin stub).
75
+ - slug: public_dataset
76
+ displayName: Public dataset
77
+ icon: database
78
+ color: blue
79
+ description: A French public dataset consulted via official open-data APIs (data.gouv.fr / api.gouv.fr / PISTE) — never scraped HTML.
80
+ scope: WORKSPACE
81
+ properties:
82
+ - slug: sourceUrl
83
+ label: Source URL
84
+ valueType: string
85
+ inputType: url
86
+ - slug: license
87
+ label: License
88
+ valueType: string
89
+ inputType: text
90
+ - slug: publisher
91
+ label: Publisher
92
+ valueType: string
93
+ inputType: text
94
+
95
+ views:
96
+ - name: Public datasets
97
+ slug: civic-france-public-datasets
98
+ type: table
99
+ scopeProfileSlug: public_dataset
100
+ description: French public datasets tracked in this research workspace.
101
+ config:
102
+ columns: [title, publisher, license, sourceUrl]
@@ -0,0 +1,97 @@
1
+ /**
2
+ * FRESHNESS GATE for the BUILT `dist/` artifact.
3
+ *
4
+ * WHY (read before deleting):
5
+ * `templates-freshness.test.ts` (sibling file) proves `src/templates.ts`
6
+ * matches `src/*.yaml`. That is necessary but not sufficient: `dist/` — built
7
+ * by `tsup` from `src/templates.ts` — is what `synap-backend` and every other
8
+ * consumer actually imports as the published `@synap-core/workspace-templates`
9
+ * npm dependency (see this package's `exports` field: `dist/index.js`).
10
+ * Nothing regenerates `dist/` automatically, and it is committed independently
11
+ * of `src/templates.ts`. A YAML edit can reach `src/templates.ts` (green on
12
+ * the sibling test) and NOT reach `dist/` — the sibling test stays green while
13
+ * the backend keeps reading stale data, because it never looks at `dist/` at
14
+ * all. That gap is exactly what happened on 2026-09-05.
15
+ *
16
+ * This test closes it by importing the BUILT `dist/index.js` module (not
17
+ * re-parsing its text — a bundled/minified file has no byte-for-byte
18
+ * relationship to `src/templates.ts`) and asserting its exported
19
+ * `WORKSPACE_TEMPLATES` deep-equals what `src/*.yaml` renders to right now.
20
+ * That is the same non-vacuous shape as the sibling test: it re-derives the
21
+ * expected value from the YAML source on every run rather than trusting any
22
+ * stored marker, and it asserts the corpus is non-empty so a broken import
23
+ * can't silently compare `{}` to `{}`.
24
+ */
25
+
26
+ import { describe, expect, it } from "vitest";
27
+ import { readYamlTemplates } from "./generate.mjs";
28
+
29
+ const FIX_COMMAND =
30
+ "cd synap-app/packages/workspace-templates && pnpm build";
31
+
32
+ describe("dist/ freshness", () => {
33
+ it("dist/index.js's WORKSPACE_TEMPLATES matches what src/*.yaml renders to", async () => {
34
+ const expectedEntries = readYamlTemplates().map((t) => [t.slug, t.data]);
35
+ const expected = Object.fromEntries(expectedEntries);
36
+
37
+ // Guard against a vacuous pass: if the YAML corpus itself came back empty
38
+ // (a broken readdir, an emptied src/ directory), an empty-vs-empty
39
+ // comparison below would prove nothing. Fail loudly instead.
40
+ if (expectedEntries.length === 0) {
41
+ throw new Error(
42
+ "readYamlTemplates() returned zero templates — src/*.yaml did not " +
43
+ "parse. This test cannot verify dist/ freshness against an empty " +
44
+ "expected corpus; fix the YAML read before trusting this gate.",
45
+ );
46
+ }
47
+
48
+ let dist: { WORKSPACE_TEMPLATES?: Record<string, unknown> };
49
+ try {
50
+ // dist/index.js is the BUILT artifact — import it live so we're
51
+ // comparing against exactly what a consumer (synap-backend, the CLI)
52
+ // resolves via this package's `exports` field, not a re-parse of its
53
+ // source text.
54
+ dist = await import("../dist/index.js");
55
+ } catch (err) {
56
+ throw new Error(
57
+ [
58
+ "",
59
+ "❌ dist/index.js could not be imported — it is missing or fails to load.",
60
+ "",
61
+ ` FIX — build it:`,
62
+ ` ${FIX_COMMAND}`,
63
+ "",
64
+ ` (original error: ${(err as Error).message})`,
65
+ "",
66
+ ].join("\n"),
67
+ );
68
+ }
69
+
70
+ const actual = dist.WORKSPACE_TEMPLATES;
71
+ if (!actual || Object.keys(actual).length === 0) {
72
+ throw new Error(
73
+ "dist/index.js loaded but exported no WORKSPACE_TEMPLATES (or an " +
74
+ `empty object) — it is stale or broken.\n\n FIX:\n ${FIX_COMMAND}\n`,
75
+ );
76
+ }
77
+
78
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
79
+ throw new Error(
80
+ [
81
+ "",
82
+ "❌ dist/index.js is STALE — its WORKSPACE_TEMPLATES no longer matches",
83
+ " what src/*.yaml renders to. synap-backend and every other consumer",
84
+ " resolve this package to dist/, not src/, so a stale dist/ means the",
85
+ " backend is reading templates that no longer exist on disk — even",
86
+ " though src/templates.ts (and templates-freshness.test.ts) are fresh.",
87
+ "",
88
+ " FIX — rebuild and commit the result:",
89
+ ` ${FIX_COMMAND}`,
90
+ "",
91
+ ].join("\n"),
92
+ );
93
+ }
94
+
95
+ expect(actual).toEqual(expected);
96
+ });
97
+ });