@uniweb/build 0.58.0 → 0.59.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,11 +57,11 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
+ "@uniweb/schemas": "^0.3.4",
61
+ "@uniweb/semantic-parser": "^1.4.1",
60
62
  "@uniweb/content-writer": "^0.3.4",
61
- "@uniweb/content-reader": "^1.2.5",
62
63
  "@uniweb/projections": "^0.7.3",
63
- "@uniweb/semantic-parser": "^1.4.1",
64
- "@uniweb/schemas": "^0.3.3",
64
+ "@uniweb/content-reader": "^1.2.5",
65
65
  "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.29.8",
78
- "@uniweb/runtime": "^0.27.4"
77
+ "@uniweb/runtime": "^0.27.4",
78
+ "@uniweb/core": "^0.29.8"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -17,6 +17,13 @@
17
17
  // ⛔ `package.json::uniweb.id` WAS A REGISTRY-NAME OVERRIDE, 2026-07-17 → 2026-09-21.
18
18
  // It is retired: the name lives in `main.js`, and two homes for one fact is how `push`
19
19
  // and `register` came to look the same foundation up under different names.
20
+ //
21
+ // ⭐ THE SCOPE IS PART OF THE NAME (2026-09-22 [Diego]) — `@acme/marketing` — so the
22
+ // org a foundation registers under lives where the rest of its name does. A bare name
23
+ // has no scope yet; `uniweb register` gives it one and writes it into `main.js`.
24
+ // ⛔ `package.json::uniweb.scope` held it until then, a second home for half of one
25
+ // fact, and the halves disagreed: a foundation named `@acme/fnd` registered its data
26
+ // schemas under whatever `uniweb.scope` said (measured: `@proximify/member`).
20
27
 
21
28
  /** Names that say where the code lives, not what the foundation is. */
22
29
  export const FORBIDDEN_FOUNDATION_NAMES = new Set(['src', 'foundation'])
@@ -61,3 +68,22 @@ export function checkFoundationName(name) {
61
68
  }
62
69
  return null
63
70
  }
71
+
72
+ /**
73
+ * A foundation's name in its two halves: the scope it registers under, and the name
74
+ * within that scope. `@acme/marketing` → `{ scope: '@acme', bare: 'marketing' }`;
75
+ * `marketing` → `{ scope: null, bare: 'marketing' }` — a name that has not registered
76
+ * yet, since `register` writes the scope it chooses into the name.
77
+ *
78
+ * ⭐ The scope is what a foundation's own `@/x` data schemas resolve into — at
79
+ * register, and in a site's records and queries, which name those same Models.
80
+ *
81
+ * @param {unknown} name
82
+ * @returns {{ scope: string|null, bare: string|null }} `scope` as `@org`
83
+ */
84
+ export function splitFoundationName(name) {
85
+ const text = typeof name === 'string' ? name.trim() : ''
86
+ if (!text) return { scope: null, bare: null }
87
+ const scoped = /^@([^/]+)\/([^/]+)$/.exec(text)
88
+ return scoped ? { scope: `@${scoped[1]}`, bare: scoped[2] } : { scope: null, bare: text }
89
+ }
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  export {
19
19
  foundationNameOf,
20
20
  checkFoundationName,
21
+ splitFoundationName,
21
22
  FORBIDDEN_FOUNDATION_NAMES,
22
23
  } from './foundation-name.js'
23
24
 
@@ -194,8 +194,10 @@ function leanDataSchema(value, dataSchemaMap) {
194
194
  if (ref) {
195
195
  const resolved = dataSchemaMap[ref]
196
196
  // ⛔ `flatRecordFields`, NOT `resolved.fields`. `dataSchemaMap` holds each
197
- // schema AS AUTHORED — resolution and lowering are different steps, and only
198
- // the second normalizes the two authoring forms. `fields:` at the top is the
197
+ // schema NORMALIZED but still in the form it was authored in — `fields:` or
198
+ // `sections:` — because only lowering (`uwx/data-schema.js`) folds the two
199
+ // forms into one. (This said "AS AUTHORED" until 2026-09-22; aliases and
200
+ // `many:` are already folded here.) `fields:` at the top is the
199
201
  // SUGAR for a one-section model; a sections-form schema has no such key, so
200
202
  // reading it directly returned null for every `@std/*` binding and the
201
203
  // section's `data:` declaration supplied no field defaults at all. Silent:
package/src/schema.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  deriveRecordsSupport,
24
24
  unnameableIn,
25
25
  } from './foundation/derive-supports.js'
26
- import { foundationNameOf } from './foundation-name.js'
26
+ import { foundationNameOf, splitFoundationName } from './foundation-name.js'
27
27
  import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
28
28
 
29
29
  // Component meta file name
@@ -191,6 +191,14 @@ export async function loadPackageJson(srcDir) {
191
191
  `\`name\` in main.js's default export (name: '${pkg.uniweb.id}').`
192
192
  )
193
193
  }
194
+ // ⛔ `uniweb.scope` likewise (retired 2026-09-22) — the scope is part of the name.
195
+ // The build never read it; `uniweb register` refuses it, naming the line to write.
196
+ if (pkg.uniweb?.scope !== undefined) {
197
+ console.warn(
198
+ `[uniweb] ${packagePath}: \`uniweb.scope\` is no longer read — a foundation's scope is ` +
199
+ `part of its name in main.js's default export (name: '@<org>/<name>').`
200
+ )
201
+ }
194
202
  return {
195
203
  name: pkg.name,
196
204
  version: pkg.version,
@@ -212,9 +220,10 @@ export async function loadPackageJson(srcDir) {
212
220
  * (`foundation-name.js`) — for `uniweb register` and for `push` / `publish` looking
213
221
  * the foundation up, which need it before a build, or without one.
214
222
  *
215
- * ⛔ Refuses `package.json::uniweb.id` (retired 2026-09-21). This is the reader
216
- * whose answer registers, so a leftover stops here rather than registering the
217
- * foundation under a different name than the one it was given.
223
+ * ⛔ Refuses `package.json::uniweb.id` (retired 2026-09-21) and `uniweb.scope`
224
+ * (retired 2026-09-22 — the scope is part of the name). This is the reader whose
225
+ * answer registers, so a leftover stops here rather than registering the foundation
226
+ * under a different name than the one it was given.
218
227
  *
219
228
  * @param {string} foundationDir - the foundation package's root
220
229
  * @returns {Promise<{ name: string|null, source: 'main.js'|'package.json'|null, mainFile: string }>}
@@ -238,12 +247,31 @@ export async function readFoundationName(foundationDir) {
238
247
  }
239
248
  const srcDir = join(foundationDir, resolveFoundationSrcDir(foundationDir))
240
249
  const config = await loadFoundationConfig(srcDir)
250
+ const read = foundationNameOf({ config, pkg })
251
+ // ⛔ `package.json::uniweb.scope` is retired (2026-09-22): a foundation's scope is
252
+ // part of its name. Refused here, the reader whose answer registers, because a
253
+ // leftover would register the name and the data schemas under two different orgs.
254
+ if (pkg.uniweb?.scope !== undefined) {
255
+ throw new Error(retiredScopeMessage(pkgPath, pkg.uniweb.scope, read.name))
256
+ }
241
257
  return {
242
- ...foundationNameOf({ config, pkg }),
258
+ ...read,
243
259
  mainFile: join(srcDir, FOUNDATION_FILE_NAMES[0]),
244
260
  }
245
261
  }
246
262
 
263
+ /**
264
+ * What to say about a leftover `package.json::uniweb.scope`: the `main.js` line that
265
+ * replaces it, or — when `main.js` already scopes the name — that it can go.
266
+ */
267
+ function retiredScopeMessage(pkgPath, scope, name) {
268
+ const { scope: nameScope, bare } = splitFoundationName(name)
269
+ const intro = `${pkgPath}: \`uniweb.scope\` is no longer read — a foundation's scope is part of its name, \`name\` in main.js's default export.`
270
+ if (nameScope) return `${intro} main.js already names it '${name}', so remove uniweb.scope.`
271
+ const handle = String(scope ?? '').replace(/^@/, '').replace(/\/.*$/, '') || '<org>'
272
+ return `${intro} Move it there (name: '@${handle}/${bare || '<name>'}') and remove uniweb.scope.`
273
+ }
274
+
247
275
  /**
248
276
  * Load the foundation's declarations from `src/main.js`.
249
277
  *
@@ -31,6 +31,20 @@
31
31
  import { existsSync, readFileSync } from 'node:fs'
32
32
  import { resolve, join } from 'node:path'
33
33
 
34
+ /**
35
+ * A catalog ref — `@org/name@version` — in its parts, or null for anything else.
36
+ *
37
+ * The grammar lives here once: `detectFoundationType` refuses a ref for a build, and a
38
+ * site's sync reads the ref's scope to qualify its `@/x` refs (`uwx/self-scope.js`).
39
+ *
40
+ * @param {unknown} value
41
+ * @returns {{ scope: string, name: string, version: string }|null} `scope` as `@org`
42
+ */
43
+ export function parseCatalogRef(value) {
44
+ const m = typeof value === 'string' ? /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(value) : null
45
+ return m ? { scope: `@${m[1]}`, name: m[2], version: m[3] } : null
46
+ }
47
+
34
48
  /**
35
49
  * Detect foundation type from the foundation config value
36
50
  *
@@ -104,8 +118,7 @@ export function detectFoundationType(foundation, siteRoot) {
104
118
  // match the documented backend selection — so `--backend`, `uniweb login
105
119
  // --backend` and the documented env var all left it pinned — and the artifact
106
120
  // names were the pre-`entry.js` ones the build stopped emitting.
107
- const orgScopedMatch = /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
108
- if (orgScopedMatch) {
121
+ if (parseCatalogRef(name)) {
109
122
  throw new Error(
110
123
  [
111
124
  `Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
@@ -368,7 +368,7 @@ async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
368
368
  * The `deferred:` a schema implies — every record field its **brief** does not name.
369
369
  *
370
370
  * ⛔ ONE IMPLEMENTATION, TWO CALLERS, and that is the point. `deriveDeferredFromSchemas`
371
- * above uses it to FILL an unstated `deferred:`; `uwx/collections-project.js` uses it to
371
+ * above uses it to FILL an unstated `deferred:`; `uwx/records-project.js` uses it to
372
372
  * RECOGNIZE a derived value on the way back in, so a pull does not write a derivation
373
373
  * into the author's file as though they had typed it.
374
374
  *
package/src/uwx/index.js CHANGED
@@ -43,7 +43,6 @@ export {
43
43
  siteProjectToDocument,
44
44
  emitSiteSyncPackage,
45
45
  writeSiteEntityUuid,
46
- writeSiteOrg,
47
46
  extensionDeclaration,
48
47
  isExtensionUrl,
49
48
  isSiteRelativeExtensionUrl,
@@ -64,6 +63,9 @@ export {
64
63
  folderToFolderYml,
65
64
  findRecordFileByUuid,
66
65
  } from './records-project.js'
66
+ // The scope a site's `@/x` refs resolve into — its foundation's. A pull resolves it
67
+ // up front and hands it to the synchronous projections above.
68
+ export { siteSelfScope } from './self-scope.js'
67
69
  export {
68
70
  siteInfoToConfig,
69
71
  sectionRecordToFile,
@@ -40,7 +40,8 @@ import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './quer
40
40
  import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
41
41
  import { folderYmlPath } from '../site/records-config.js'
42
42
  import { isContentBodyField } from './data-schema.js'
43
- import { unresolveSelfScope } from './self-scope.js'
43
+ import { unresolveSelfScope, refuseOrgOption } from './self-scope.js'
44
+ import { parseCatalogRef } from '../site/foundation-ref.js'
44
45
  import { unwrapLocalized } from './backfill.js'
45
46
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
46
47
  import { buildFreeformRecordPath } from '../i18n/freeform.js'
@@ -151,8 +152,8 @@ function indexFolder(folderDoc) {
151
152
  // `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
152
153
  // a pulled record would land somewhere the next build reads as a different
153
154
  // schema — silently, because both paths are well-formed.
154
- function recordDirFor(recordsRoot, model, selfOrg) {
155
- const dirs = poolDirsForSchema(unresolveSelfScope(model, selfOrg))
155
+ function recordDirFor(recordsRoot, model, scope) {
156
+ const dirs = poolDirsForSchema(unresolveSelfScope(model, scope))
156
157
  return dirs ? join(recordsRoot, ...dirs) : null
157
158
  }
158
159
 
@@ -173,16 +174,6 @@ function locate(document, folderIndex) {
173
174
  return fromFolder || null
174
175
  }
175
176
 
176
- /** The site's own org on `backend` — its `site.org` in `sync.json` — bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
177
- // ⛔ `site.yml::$org` IS GONE (2026-09-20) — the org lives in
178
- // `sync.json::backends.<origin>.site.org`. This read `site.yml` and, once step 4
179
- // moved the key, silently returned null: every `@org/x` model a pull met would have
180
- // been placed back as if the site had no org. No test spanned it.
181
- function readSiteOrg(siteRoot, backend) {
182
- const org = backend ? readBackendState(siteRoot, backend).site?.org : null
183
- return typeof org === 'string' && org ? org : null
184
- }
185
-
186
177
  // Skip undefined when copying optional fields into a projected declaration.
187
178
  function setIf(obj, key, value) {
188
179
  if (value !== undefined) obj[key] = value
@@ -239,15 +230,15 @@ function isDerivedDeferred(d, dataSchemas) {
239
230
  return d.deferred.every((f) => a.has(f))
240
231
  }
241
232
 
242
- function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
233
+ function declToFileShape(wire, dataSchemas = null, scope = null) {
243
234
  // ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
244
- // `schema`. The push qualifies a foundation-relative `@/x` to `@org/x`
235
+ // `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
245
236
  // (`site.js::queriesNested`), and both checks below are keyed by the author's
246
- // `@/x`: against `@org/x` the query-name default would never match — writing an
237
+ // `@/x`: against `@scope/x` the query-name default would never match — writing an
247
238
  // explicit schema the author never had — and the derived-`deferred` lookup would
248
239
  // miss, persisting a derivation into their file (the 2026-08-29 defect).
249
- const d = selfOrg && typeof wire.schema === 'string'
250
- ? { ...wire, schema: unresolveSelfScope(wire.schema, selfOrg) }
240
+ const d = scope && typeof wire.schema === 'string'
241
+ ? { ...wire, schema: unresolveSelfScope(wire.schema, scope) }
251
242
  : wire
252
243
  const name = d.name || d.$id
253
244
  const decl = {}
@@ -329,14 +320,14 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
329
320
  * @param {object} params
330
321
  * @param {object} params.document - a site-content `$`-document (`{ queries }`)
331
322
  * @param {string} params.siteRoot
332
- * @param {string} [params.org] - the site's own org, so a `schema` the producer
333
- * qualified from `@/x` is written back as `@/x`. Defaults to the org
334
- * `sync.json` records for `backend`, the same default `recordsToProject`
335
- * places records by.
336
- * @param {string|null} [params.backend] - whose recorded org is that default
323
+ * @param {string|null} [params.scope] - the scope the push qualified a `schema` from
324
+ * `@/x` with — the site's foundation's — so it is written back as `@/x`.
325
+ * Defaults to the scope of the foundation ref the pulled document carries
326
+ * (`info.foundation`, the pinned `@org/name@version`), which is that scope.
337
327
  * @returns {{ collections?: 'updated'|'unchanged' }}
338
328
  */
339
- export function declarationsToQueriesYml({ document, siteRoot, org, backend = null }) {
329
+ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest }) {
330
+ refuseOrgOption(rest, 'declarationsToQueriesYml')
340
331
  const decls = Array.isArray(document?.queries) ? document.queries : []
341
332
  const report = {}
342
333
  if (decls.length === 0) return report
@@ -354,11 +345,12 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
354
345
  siteYml = null
355
346
  }
356
347
  const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
357
- const selfOrg = org ?? readSiteOrg(siteRoot, backend)
348
+ const selfScope =
349
+ scope !== undefined ? scope : (parseCatalogRef(document?.info?.foundation)?.scope ?? null)
358
350
 
359
351
  const queries = {}
360
352
  for (const d of decls) {
361
- const { name, decl } = declToFileShape(d, dataSchemas, selfOrg)
353
+ const { name, decl } = declToFileShape(d, dataSchemas, selfScope)
362
354
  if (!name) continue
363
355
  queries[name] = decl
364
356
  }
@@ -469,20 +461,21 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
469
461
  * @param {object} params.opts
470
462
  * @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
471
463
  * - resolve a Model's data-schema declaration by name (`$model`).
472
- * @param {string} [params.opts.org] - the site's own org, so a `@org/x` model the
473
- * producer resolved from `@/x` is placed back where the author wrote it.
474
- * Defaults to the org `sync.json` records for `opts.backend`.
475
- * @param {string} [params.opts.backend] - whose record map to read and extend, and
476
- * whose recorded org is that default
464
+ * @param {string|null} [params.opts.scope] - the scope the push qualified a record's
465
+ * `@/x` Model with — the site's foundation's — so a `@scope/x` model is placed
466
+ * back where the author wrote it. The caller resolves it up front, as it does
467
+ * Models (`siteSelfScope`); absent, a model is placed under its own scope.
468
+ * @param {string} [params.opts.backend] - whose record map to read and extend
477
469
  * @param {string} [params.opts.sourceLocale]
478
470
  * @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
479
471
  */
480
472
  export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
481
473
  const { resolveDeclaration, sourceLocale = 'en' } = opts
482
- // The site's own org, so a `@org/x` model the producer resolved from `@/x` is
483
- // placed back where the author wrote it. Read from `sync.json` (this backend's
484
- // `site.org`) unless the caller already has it.
485
- const selfOrg = opts.org ?? readSiteOrg(siteRoot, opts.backend)
474
+ refuseOrgOption(opts, 'recordsToProject')
475
+ // The scope the push qualified `@/x` with — the site's foundation's — so a
476
+ // `@scope/x` model is placed back where the author wrote it. ⛔ It was the site
477
+ // owner's org, read from `sync.json`, until 2026-09-22 (`self-scope.js`).
478
+ const selfScope = opts.scope ?? null
486
479
  if (typeof resolveDeclaration !== 'function') {
487
480
  throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
488
481
  }
@@ -525,7 +518,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
525
518
  continue
526
519
  }
527
520
 
528
- const poolDir = recordDirFor(recordsRoot, document.$model, selfOrg)
521
+ const poolDir = recordDirFor(recordsRoot, document.$model, selfScope)
529
522
  if (!poolDir) {
530
523
  skipped.push({
531
524
  uuid: document.$uuid,
@@ -57,7 +57,7 @@ import {
57
57
  } from '../site/entity-pool.js'
58
58
  import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
59
59
  import { emitEntitySyncPackage } from './entity-document.js'
60
- import { resolveSelfScope } from './self-scope.js'
60
+ import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
61
61
  import { sha256Hex, toJsonBuffer } from './manifest.js'
62
62
  import { markdownToProseMirror } from '@uniweb/content-reader'
63
63
  import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
@@ -554,6 +554,9 @@ function resolveDeclaration(schema, modelName) {
554
554
  * `@uniweb/data-schema` declaration (or null). The verb wires this to the
555
555
  * backend's Model-read route. Without it, the local foundation is required.
556
556
  * @param {string} [opts.sourceLocale] - localized-field wrap locale
557
+ * @param {string|null} [opts.scope] - the scope a record's `@/x` Model resolves into.
558
+ * Defaults to the site's foundation's (`siteSelfScope`); a caller that has it
559
+ * already passes it, so one emit reads it once.
557
560
  * @returns {Promise<{ entities: object[], index: object[], warnings: string[],
558
561
  * schemaless: Array<{name: string, model: string}>, colConfig: object,
559
562
  * folder: object, recordsDirExists: boolean }>}
@@ -585,6 +588,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
585
588
  // twice and the duplicate check refused the push (measured: "appears in more than
586
589
  // one query"), and a record no query read was never pushed. Records are walked
587
590
  // once, by the schema their folder declares.
591
+ refuseOrgOption(opts, 'uwx/records')
588
592
  const pool = await readEntityPool(siteRoot)
589
593
  const recordsCfg = await readRecordsConfig(siteRoot, { dir: pool.dir })
590
594
  if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
@@ -657,19 +661,25 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
657
661
  //
658
662
  // ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
659
663
  // (`site.js::queriesNested`): a query's `schema` must name exactly the Model
660
- // these records are stored under, so both go through one function with one org.
664
+ // these records are stored under, so both go through one function with one scope.
665
+ //
666
+ // ⭐ THE SCOPE IS THE FOUNDATION'S — the one `register` stored these Models under —
667
+ // never the site owner's (2026-09-22; `self-scope.js` has the record).
668
+ const scope =
669
+ opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
661
670
  const warnedUnscoped = new Set()
662
671
  const modelFor = (schema, where) => {
663
- const modelName = resolveSelfScope(schema, opts.org)
664
- // Unresolvable `@/` — no org is known. Ship it rather than throwing (a `status`
665
- // probe on a never-pushed site has no org and must still count), but say so:
666
- // the backend's refusal names a missing Model and cannot name this cause.
672
+ const modelName = resolveSelfScope(schema, scope)
673
+ // Unresolvable `@/` — the foundation has no scope yet. Ship it rather than
674
+ // throwing (a `status` probe on a never-registered foundation must still count),
675
+ // but say so: the backend's refusal names a missing Model and cannot name this cause.
667
676
  if (modelName === schema && typeof schema === 'string' && schema.startsWith('@/') && !warnedUnscoped.has(schema)) {
668
677
  warnedUnscoped.add(schema)
669
678
  warnings.push(
670
- `${where}: \`${schema}\` is foundation-relative and no org is known, ` +
679
+ `${where}: \`${schema}\` is foundation-relative and the foundation has no scope yet, ` +
671
680
  `so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
672
- `Pass \`--org @handle\`, or push once so the site records its org.`
681
+ `A foundation's scope is part of its name — \`name: '@org/<name>'\` in its main.js, ` +
682
+ `which \`uniweb register\` writes.`
673
683
  )
674
684
  }
675
685
  return modelName
@@ -863,8 +873,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
863
873
  ? !unresolved.has(schema)
864
874
  : Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
865
875
  if (!resolved) {
866
- if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, opts.org), name)
867
- schemaless.push({ name, model: resolveSelfScope(schema, opts.org) })
876
+ if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
877
+ schemaless.push({ name, model: resolveSelfScope(schema, scope) })
868
878
  continue
869
879
  }
870
880
  if (!hasRecords) {
@@ -17,16 +17,17 @@
17
17
  * `@uniweb/data-schema` entities, no foundation-schema — for a schemas-only
18
18
  * package (the standard schemas under `@std`, or an org's own `@org/schemas`).
19
19
  *
20
- * Scope: pass `scope` ('@acme' or 'acme') to resolve a schema's own `@/x` (and the
21
- * foundation's `data-schemas.refs`) to a concrete `@acme/x` for submission. With no
22
- * `scope`, names stay `@/x` (local preview / dry-run). See `uwx-format.md`.
20
+ * Scope: a foundation whose name is scoped (`@acme/fnd`) registers under that scope —
21
+ * its name and its schemas' own `@/x` alike. A bare name takes `scope` ('@acme' or
22
+ * 'acme'). With neither, names stay `@/x` (local preview / dry-run). A schemas-only
23
+ * package has no name to carry one, so it always takes `scope`. See `uwx-format.md`.
23
24
  */
24
25
 
25
26
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
26
27
  import { join } from 'node:path'
27
28
 
28
29
  import { toDataSchemaDeclaration } from './data-schema.js'
29
- import { checkFoundationName } from '../foundation-name.js'
30
+ import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
30
31
 
31
32
  const FOUNDATION_SCHEMA = '@uniweb/foundation-schema'
32
33
  const DATA_SCHEMA = '@uniweb/data-schema'
@@ -46,7 +47,9 @@ try {
46
47
  * @param {Object} params
47
48
  * @param {Object} params.schema - parsed `dist/meta/schema.json`.
48
49
  * @param {string} [params.foundationDir] - foundation root, for the `i18n/` bundles.
49
- * @param {string} [params.scope] - org scope (`@acme` or `acme`) resolving `@/x` -> `@acme/x`.
50
+ * @param {string} [params.scope] - org scope (`@acme` or `acme`) resolving `@/x` -> `@acme/x`,
51
+ * for a foundation whose name is bare. A scoped name's own scope is used instead, and
52
+ * a `scope` that contradicts it throws.
50
53
  * @param {Object} [params.exporter] - `{ tool, version, instance }` for the envelope.
51
54
  * @param {string} [params.exportedAt] - ISO timestamp (default: now).
52
55
  * @param {string} [params.runtime] - the `@uniweb/runtime` version this build
@@ -70,7 +73,10 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
70
73
  // own `@/x`), each resolved to the concrete publish scope. Shared refs
71
74
  // (`@std/x`, `@other/x`) are named in the foundation's data-schemas.refs but
72
75
  // their declarations are not bundled — already published.
73
- const { entities: dataSchemaEntities, scoped, org } = buildDataSchemaEntities(dataSchemas, scope)
76
+ const { entities: dataSchemaEntities, scoped, org } = buildDataSchemaEntities(
77
+ dataSchemas,
78
+ registrationScope(self.name, scope)
79
+ )
74
80
 
75
81
  const foundationEntity = {
76
82
  model: FOUNDATION_SCHEMA,
@@ -108,6 +114,27 @@ export function buildSchemaOnlyPackage({ schemas, scope, exporter, exportedAt }
108
114
  return wrapEntities(entities, exporter, exportedAt)
109
115
  }
110
116
 
117
+ // The scope a foundation registers under: the one in its name, else `scope`.
118
+ //
119
+ // ⭐ A SCOPED NAME IS THE SCOPE (2026-09-22). `@acme/fnd` registers under `@acme` —
120
+ // the foundation and its data schemas both, because a site's records name those
121
+ // schemas by the same scope. ⛔ Until then the schemas took `scope` while the name
122
+ // kept its own, so one foundation registered `@acme/fnd` beside `@proximify/member`.
123
+ // A `scope` that contradicts the name is refused rather than chosen between: which
124
+ // org was meant is not ours to guess.
125
+ function registrationScope(name, scope) {
126
+ const { scope: nameScope } = splitFoundationName(name)
127
+ const handle = scope ? String(scope).replace(/^@/, '').replace(/\/.*$/, '') : ''
128
+ const given = handle ? `@${handle}` : null
129
+ if (nameScope && given && given !== nameScope) {
130
+ throw new Error(
131
+ `buildRegistryPackage: the foundation is named ${name}, so it registers under ` +
132
+ `${nameScope} — not ${given}. A foundation's scope is the one in its name.`
133
+ )
134
+ }
135
+ return nameScope || scope
136
+ }
137
+
111
138
  // Lower a `{ '@/<name>': normalizedSchema }` map into the sorted
112
139
  // `@uniweb/data-schema` entity list, resolving each own `@/x` to the publish
113
140
  // scope. Shared by the foundation publish (buildRegistryPackage) and the
@@ -1,9 +1,9 @@
1
- // `@/x` — a Model ref in the site's own foundation scope — and its qualified form.
1
+ // `@/x` — a Model ref in the site's foundation's own scope — and its qualified form.
2
2
  //
3
3
  // `@/member` names a data schema in the foundation's own `schemas/`. It is
4
4
  // AUTHORING shorthand: a backend resolves Models by name and never mints one, so a
5
5
  // ref that leaves the CLI must be the name the Model was stored under,
6
- // `@<org>/member`. Everything that reads a ref off the wire matches it exactly —
6
+ // `@<scope>/member`. Everything that reads a ref off the wire matches it exactly —
7
7
  // the entity store at restore, and a records service answering a hosted page's
8
8
  // query.
9
9
  //
@@ -13,7 +13,7 @@
13
13
  // - a query's `schema` `site.js::queriesNested` (the `queries` Section)
14
14
  // - the author's spelling, on pull `records-project.js` (placement and declarations)
15
15
  //
16
- // ⛔ They must agree on one alias. When the query path shipped `@/member` verbatim
16
+ // ⛔ They must agree on one scope. When the query path shipped `@/member` verbatim
17
17
  // while the records beside it were qualified, the query named a Model its own
18
18
  // records were not stored under: a hosted page's question named a Model that does
19
19
  // not exist, the records service refused that key, and the section rendered
@@ -21,39 +21,52 @@
21
21
  // `block.dataError`, the console clean. Nothing on either side was malformed; the
22
22
  // two paths disagreed.
23
23
  //
24
- // Registering a foundation qualifies its declarations separately, from the
25
- // foundation's publish scope (`registry-package.js`). That is a different input
26
- // (the foundation's scope, not the site's publish org) and stays there.
24
+ // ⭐ THE SCOPE IS THE FOUNDATION'S — the one in its name (`@acme/marketing`), which is
25
+ // the scope `register` stored the foundation's own data schemas under
26
+ // (`registry-package.js`). ⛔ Until 2026-09-22 every path above qualified with the org
27
+ // that owns the SITE instead — a stand-in that holds only when one org owns both, and
28
+ // a foundation may be registered under any org its author belongs to [Diego]. A site
29
+ // owned by `@client` on a foundation registered as `@acme/fnd` shipped its records as
30
+ // `@client/member`, a Model `register` never stored; a personal site shipped
31
+ // `@/member` unresolved (both measured). `siteSelfScope` is where the scope comes from.
32
+
33
+ import { readFileSync } from 'node:fs'
34
+ import { join, resolve } from 'node:path'
35
+ import yaml from 'js-yaml'
36
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
37
+ import { detectFoundationType, parseCatalogRef } from '../site/foundation-ref.js'
38
+ import { readFoundationName } from '../schema.js'
39
+ import { splitFoundationName } from '../foundation-name.js'
27
40
 
28
41
  /**
29
- * An org handle as `--org` or `sync.json` gives it — `@acme`, `acme`, or
30
- * `@acme/…` — reduced to the bare handle, or `''`.
42
+ * A scope as it may be written — `@acme`, `acme`, or `@acme/…` — reduced to the bare
43
+ * handle, or `''`.
31
44
  *
32
- * @param {unknown} org
45
+ * @param {unknown} scope
33
46
  * @returns {string}
34
47
  */
35
- export function bareOrg(org) {
36
- return typeof org === 'string' ? org.replace(/^@/, '').replace(/\/.*$/, '') : ''
48
+ export function bareOrg(scope) {
49
+ return typeof scope === 'string' ? scope.replace(/^@/, '').replace(/\/.*$/, '') : ''
37
50
  }
38
51
 
39
52
  /**
40
- * `@/x` → `@<org>/x`. Any other ref (`@std/x`, `@acme/x`) passes through, and so
41
- * does `@/x` when no org is known — callers that ship an unresolved alias say so
53
+ * `@/x` → `@<scope>/x`. Any other ref (`@std/x`, `@acme/x`) passes through, and so
54
+ * does `@/x` when no scope is known — callers that ship an unresolved alias say so
42
55
  * themselves (`buildRecordEntities` warns per query).
43
56
  *
44
57
  * @param {unknown} ref
45
- * @param {unknown} org
58
+ * @param {unknown} scope - the site's foundation's scope (`siteSelfScope`)
46
59
  * @returns {unknown}
47
60
  */
48
- export function resolveSelfScope(ref, org) {
49
- const handle = bareOrg(org)
61
+ export function resolveSelfScope(ref, scope) {
62
+ const handle = bareOrg(scope)
50
63
  return typeof ref === 'string' && ref.startsWith('@/') && handle
51
64
  ? `@${handle}/${ref.slice(2)}`
52
65
  : ref
53
66
  }
54
67
 
55
68
  /**
56
- * The inverse, for a pull: `@<org>/x` → `@/x` for the site's own org.
69
+ * The inverse, for a pull: `@<scope>/x` → `@/x` for the site's foundation's scope.
57
70
  *
58
71
  * ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
59
72
  * on both ends. A record authored under `records/article/` comes back as
@@ -63,16 +76,90 @@ export function resolveSelfScope(ref, org) {
63
76
  * relied on the query-name default comes back with an explicit schema it never
64
77
  * had.
65
78
  *
66
- * ⭐ The site records its own org at create (`site.org` in `sync.json`, per backend —
67
- * "whose this is"), which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
68
- * genuinely is that org's, and `@/` would be a lie.
79
+ * A model scoped to ANY OTHER scope is left alone: it genuinely is that org's, and
80
+ * `@/` would be a lie.
69
81
  *
70
82
  * @param {unknown} ref
71
- * @param {unknown} org
83
+ * @param {unknown} scope - the scope the pulled records were qualified with
72
84
  * @returns {unknown}
73
85
  */
74
- export function unresolveSelfScope(ref, org) {
75
- const handle = bareOrg(org)
86
+ export function unresolveSelfScope(ref, scope) {
87
+ const handle = bareOrg(scope)
76
88
  if (typeof ref !== 'string' || !handle) return ref
77
89
  return ref.startsWith(`@${handle}/`) ? `@/${ref.slice(handle.length + 2)}` : ref
78
90
  }
91
+
92
+ /**
93
+ * The scope a site's `@/x` refs resolve into: its foundation's, `@org` — or null
94
+ * when it has none yet.
95
+ *
96
+ * - `foundation` given (a pull passes the pinned ref the pushed site carries) — a
97
+ * catalog ref's scope, or a local foundation's, resolved from that value.
98
+ * - else `foundationDir` — that local foundation's.
99
+ * - else `site.yml::foundation`, the same way.
100
+ *
101
+ * A local foundation's scope is the one in its name, read by the one rule
102
+ * (`readFoundationName`, the reader `register` and `push` use), so what a push pins
103
+ * and what it qualifies with cannot differ. A bare name has no scope until `register`
104
+ * gives it one; a URL foundation has none.
105
+ *
106
+ * ⚠️ A foundation whose name cannot be read (a leftover `uniweb.scope`, a `main.js`
107
+ * that fails to load) answers null here. `register` — which a push and a publish run
108
+ * first — says why; this only qualifies.
109
+ *
110
+ * @param {string} siteRoot
111
+ * @param {{ foundation?: unknown, foundationDir?: string }} [opts]
112
+ * @returns {Promise<string|null>}
113
+ */
114
+ export async function siteSelfScope(siteRoot, { foundation, foundationDir } = {}) {
115
+ if (foundation === undefined && foundationDir) return localFoundationScope(resolve(foundationDir))
116
+ const declared = foundation !== undefined ? foundation : readDeclaredFoundation(siteRoot)
117
+ const value = declared && typeof declared === 'object' ? declared.name : declared
118
+ if (typeof value !== 'string' || !value) return null
119
+ const ref = parseCatalogRef(value)
120
+ if (ref) return ref.scope
121
+ let info = null
122
+ try {
123
+ info = detectFoundationType(value, siteRoot)
124
+ } catch {
125
+ return null
126
+ }
127
+ return info?.type === 'local' && info.path ? localFoundationScope(info.path) : null
128
+ }
129
+
130
+ async function localFoundationScope(dir) {
131
+ try {
132
+ const { name } = await readFoundationName(dir)
133
+ return splitFoundationName(name).scope
134
+ } catch {
135
+ return null
136
+ }
137
+ }
138
+
139
+ function readDeclaredFoundation(siteRoot) {
140
+ try {
141
+ const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
142
+ return siteYml.foundation ?? null
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Refuse the retired `org` option.
150
+ *
151
+ * ⛔ It carried the org that owns the SITE, and qualified a site's `@/x` refs with it
152
+ * (until 2026-09-22). Ignoring it would be silent, so a caller still passing it is told
153
+ * what replaced it: the foundation's scope, derived — or `scope`, to state it.
154
+ *
155
+ * @param {object|undefined} opts
156
+ * @param {string} where - the function, for the message
157
+ */
158
+ export function refuseOrgOption(opts, where) {
159
+ if (opts && opts.org !== undefined) {
160
+ throw new Error(
161
+ `${where}: \`org\` is no longer read — a site's \`@/x\` refs resolve into its ` +
162
+ `foundation's scope (the one in the foundation's name). Pass \`scope\` to state it.`
163
+ )
164
+ }
165
+ }
@@ -868,9 +868,12 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
868
868
  * files that have no corresponding incoming item (git-pull-like). Off by
869
869
  * default; `uniweb pull` opts in. Guarded against wiping a level on an
870
870
  * empty set.
871
+ * @param {string|null} [params.scope] - the scope the push qualified a query's
872
+ * `@/x` schema with, so it is written back as `@/x` — see
873
+ * `declarationsToQueriesYml`, whose default it overrides.
871
874
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
872
875
  */
873
- export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false }) {
876
+ export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope }) {
874
877
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
875
878
 
876
879
  // Collects target-locale translations of localized scalars as they're projected;
@@ -893,7 +896,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
893
896
  )
894
897
 
895
898
  report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
896
- report.queries = declarationsToQueriesYml({ document, siteRoot, backend })
899
+ report.queries = declarationsToQueriesYml({ document, siteRoot, scope })
897
900
 
898
901
  // The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
899
902
  // rename detection, build a fresh one as we project, then persist it. Items not
package/src/uwx/site.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Map a file site project to one `@uniweb/site-content` entity, as the
2
- // section-keyed `-document (docs/reference/entity-content.md), then to a
2
+ // section-keyed `$`-document (docs/reference/entity-content.md), then to a
3
3
  // `subtype: entity` .uwx on the SYNC lane.
4
4
  //
5
5
  // SOURCE LAYER: the content-collector's return is flattened and lossy for
@@ -8,9 +8,13 @@
8
8
  // processMarkdownFile, ordering, mode detection) so those semantics stay
9
9
  // identical to a normal build — only the directory/mode/order *walk* is ours.
10
10
  //
11
- // The document mirrors the @uniweb/site-content Model: `info` (brief) · `pages`
12
- // (self-nesting; each page carries its `page_sections` as an inline field) ·
13
- // `layout_sections` · `extensions` · `queries`. `info.foundation`
11
+ // The document mirrors the @uniweb/site-content Model: `info` (brief) · `settings`
12
+ // · `pages` (self-nesting; each page carries its `page_sections` as an inline
13
+ // field) · `layout_sections` · `extensions` · `queries` · `services` · `secrets`.
14
+ // `settings` is emitted only when non-empty; `services` and `secrets` only when the
15
+ // backend being pushed to has that key in `sync.json` (absent is not empty — see
16
+ // below). `emit-surface.json` states which `site.yml` key lands in `info` and
17
+ // `settings`. `info.foundation`
14
18
  // carries the verbatim `site.yml::foundation` string (the round-trip source of
15
19
  // truth).
16
20
  //
@@ -73,7 +77,7 @@ import { loadFreeformTranslation } from '../i18n/freeform.js'
73
77
  import { updateBackendState, readBackendState } from './sync-store.js'
74
78
  import { upsertYamlScalar } from './yaml-upsert.js'
75
79
  import { resolveQueriesConfig } from './queries-config.js'
76
- import { resolveSelfScope } from './self-scope.js'
80
+ import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
77
81
  import { siteContentDirs } from './site-dirs.js'
78
82
 
79
83
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
@@ -915,7 +919,7 @@ function externalSource(d) {
915
919
  return source
916
920
  }
917
921
 
918
- function queriesNested(declarations, uuids = null, org = null) {
922
+ function queriesNested(declarations, uuids = null, scope = null) {
919
923
  const out = []
920
924
  for (const [name, d] of Object.entries(declarations)) {
921
925
  refuseUnder(d.where, `queries.${name}`)
@@ -926,13 +930,13 @@ function queriesNested(declarations, uuids = null, org = null) {
926
930
  // the runtime fetches it from (never the records service).
927
931
  const source = d.path ? { path: d.path } : d.url !== undefined ? externalSource(d) : d.source
928
932
  setIf(data, 'source', source)
929
- // ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME ORG AS THE RECORDS' `$model`
930
- // (`records.js::buildRecordEntities`). A consumer answers a query by matching
931
- // this name against the Models its records were stored under, so a verbatim
932
- // `@/member` beside records stored as `@org/member` names nothing: the query
933
- // resolves no Model and the page that binds it renders empty. The pull puts the
934
- // author's `@/` back (`records-project.js::declarationsToQueriesYml`).
935
- setIf(data, 'schema', resolveSelfScope(d.schema, org))
933
+ // ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME SCOPE AS THE RECORDS' `$model`
934
+ // (`records.js::buildRecordEntities`) — the site's foundation's. A consumer
935
+ // answers a query by matching this name against the Models its records were
936
+ // stored under, so a verbatim `@/member` beside records stored as `@acme/member`
937
+ // names nothing: the query resolves no Model and the page that binds it renders
938
+ // empty. The pull puts the author's `@/` back (`records-project.js::declarationsToQueriesYml`).
939
+ setIf(data, 'schema', resolveSelfScope(d.schema, scope))
936
940
  setIf(data, 'sort', d.sort)
937
941
  // Legacy `filter:` is not synced — it is translated to `where` upstream
938
942
  // (the canonical predicate). No legacy fields on the wire.
@@ -1009,16 +1013,19 @@ function queriesNested(declarations, uuids = null, org = null) {
1009
1013
  // ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
1010
1014
  // REPLACED by what we send, so `[]` means "drop every stored config row" while a
1011
1015
  // missing key means "I am not telling you about this". A project that has never
1012
- // pulled has no `$services`, and its ordinary push must not read as a request to
1013
- // wipe a service the operator configured in the app. So: emit the Section only when
1014
- // the file declares the key. Clearing is available and explicit — `$services: []`.
1016
+ // pulled has no `services` for that backend, and its ordinary push must not read as
1017
+ // a request to wipe a service the operator configured in the app. So: emit the
1018
+ // Section only when that backend's entry in `sync.json` carries the key. Clearing is
1019
+ // available and explicit — `services: []` there. *(Both said `site.yml::$services`
1020
+ // until the move on 2026-09-20.)*
1015
1021
  //
1016
1022
  // ⚠️ The push gate is NOT what makes this safe, though it usually catches it: its
1017
1023
  // tokens live in a gitignored per-clone cache, so a fresh clone pushes
1018
1024
  // unconditionally. Correctness has to sit here.
1019
1025
 
1020
1026
  /**
1021
- * `$services` / `$secrets` → Section records, or undefined when the key is absent.
1027
+ * A backend's provisioned `services` / `secrets` (its `sync.json` entry) → Section
1028
+ * records, or undefined when the key is absent.
1022
1029
  *
1023
1030
  * ⭐ PASSTHROUGH, NOT AN ALLOWLIST — the same rule and the same reason as
1024
1031
  * `queriesNested` above. The field set belongs to the backend's Model, a service's
@@ -1027,7 +1034,8 @@ function queriesNested(declarations, uuids = null, org = null) {
1027
1034
  * would DESTROY whatever is stored under it on every push. Framework can enumerate
1028
1035
  * its own vocabulary and cannot enumerate theirs; withhold ours, forward the rest.
1029
1036
  *
1030
- * @param {*} declared - the raw `$services` / `$secrets` value from site.yml
1037
+ * @param {*} declared - the raw `services` / `secrets` value from that backend's
1038
+ * `sync.json` entry
1031
1039
  * @param {(entry: object) => string|null} identify - the record's stable `$id`
1032
1040
  * @param {string} label - the key name, for the one warning below
1033
1041
  * @returns {object[]|undefined}
@@ -1229,13 +1237,16 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1229
1237
  * @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
1230
1238
  * the site's effective default locale (`defaultLanguage || languages[0] ||
1231
1239
  * 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
1232
- * @param {string} [opts.org] - the publish org, which qualifies a query's
1233
- * foundation-relative `schema` (`@/x` → `@org/x`). Pass the same org the
1234
- * records are emitted with; absent, `@/x` ships as written.
1240
+ * @param {string|null} [opts.scope] - the scope a query's foundation-relative `schema`
1241
+ * resolves into (`@/x` → `@scope/x`): the site's foundation's. Defaults to
1242
+ * `siteSelfScope`; pass the scope the records are emitted with. With none
1243
+ * known, `@/x` ships as written.
1235
1244
  * @returns {Promise<object>} the section-keyed `$`-document:
1236
- * `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, queries }`
1245
+ * `{ $uuid?, $id, $model, info, settings?, pages, layout_sections, extensions,
1246
+ * queries, services?, secrets? }`
1237
1247
  */
1238
1248
  export async function siteProjectToDocument(siteRoot, opts = {}) {
1249
+ refuseOrgOption(opts, 'uwx/site')
1239
1250
  const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
1240
1251
  const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
1241
1252
  if (!siteYml.name) {
@@ -1533,7 +1544,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1533
1544
  //
1534
1545
  // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1535
1546
  // consumer sees, leave the identifier alone.
1536
- doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, opts.org)
1547
+ const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
1548
+ doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope)
1537
1549
  // Emitted ONLY when the file declares the key — see the header above
1538
1550
  // `serviceRecords`: on a replaced Section, absent and empty are different
1539
1551
  // requests and one of them is destructive.
@@ -1618,53 +1630,13 @@ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1618
1630
  return updateBackendState(siteRoot, backend, { site: { uuid } })
1619
1631
  }
1620
1632
 
1621
- /**
1622
- * Record the org the site was CREATED under — `backend`'s `site.org` in `sync.json`,
1623
- * beside the site's uuid. (It was `site.yml::$org` until 2026-09-20.)
1624
- *
1625
- * The org is consumed at exactly one moment — the `as_org` on the create that
1626
- * mints `$uuid` — and after that the uuid carries the ownership binding. So this
1627
- * is not a knob the backend re-reads; it is the answer to *"whose workspace is
1628
- * this site's storage charged to?"*, which `$uuid` alone cannot answer and which
1629
- * otherwise costs a backend round-trip (or is simply unknowable from the repo).
1630
- *
1631
- * Stored as the BARE handle, never `@handle` — the canonical form everywhere else
1632
- * (`deriveScope` returns it, `createOrg` echoes it as `org.handle`,
1633
- * `validateHandle` validates it); the `@` is display sugar the reader re-adds. In
1634
- * `site.yml` it was also the only form that parsed: `@` is a reserved YAML
1635
- * indicator, so `$org: @acme` is an error.
1636
- *
1637
- * ⛔ NOT `deploy.yml`, though that file already holds the bound `backend` and the
1638
- * two look like the same class of fact. Four reasons, and the first is the one that
1639
- * settles it:
1640
- *
1641
- * 1. CARDINALITY. `deploy.yml` is multi-TARGET and `backend` sits *under* a target,
1642
- * so its shape says "this may vary per target." An org may not: one site has one
1643
- * owning org, fixed at create and preserved on replace. The org is a property of
1644
- * the site's uuid, and sits beside it in the same backend's section — filing it
1645
- * under a target would encode a freedom that does not exist.
1646
- * 2. WHO WRITES. Three paths mint a site (`ensureSiteExists`, the media-less push's
1647
- * content-lane create, and `clone` seeding an existing one) and **none of them
1648
- * write `deploy.yml`** — only `deploy` and `publish` call `recordLastDeploy`. The
1649
- * record would exist or not depending on which verb the developer reached for.
1650
- * 3. SUPPRESSIBLE. `recordLastDeploy` is a no-op under `autoSave: off` / `--no-save`.
1651
- * Turning off deploy *receipts* would silently drop an *ownership* record.
1652
- * 4. SEMANTICS. `deploy.yml` describes the act of shipping (`lastDeploy` is a
1653
- * receipt) and is optional entirely. But `push` creates a site and never
1654
- * publishes — a site can exist, be owned, and accrue storage charges without
1655
- * ever being deployed. Ownership does not belong in a record of a deploy that
1656
- * may not have happened.
1657
- *
1658
- * `backend` answers *where this ships*; `site.org` answers *whose this is*.
1659
- *
1660
- * @param {string} siteRoot
1661
- * @param {string} backend - the origin of the backend the site was created on
1662
- * @param {string} handle - the bare org handle (no leading `@`)
1663
- * @returns {boolean} true if sync.json changed
1664
- */
1665
- export function writeSiteOrg(siteRoot, backend, handle) {
1666
- return updateBackendState(siteRoot, backend, { site: { org: handle } })
1667
- }
1633
+ // ⛔ `writeSiteOrg` is GONE (2026-09-23). The workspace a project's requests name is two
1634
+ // keys of `site` now — `org` (a handle, bare) and `unit` (the uuid of a workspace with no
1635
+ // handle), at most one set — and every site request names it, so it is no longer a note
1636
+ // written once at the create. A writer of one key would leave the other stale; write
1637
+ // both through `updateBackendState`. Why it lives here rather than in `deploy.yml`: a
1638
+ // site has one workspace per backend, fixed by the create, while `deploy.yml` is per
1639
+ // target and records the act of shipping, which `push` never does.
1668
1640
 
1669
1641
  // ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
1670
1642
  // no longer a value recorded beside the uuid — it is the KEY the identity is stored
@@ -23,6 +23,7 @@ import { buildRecordEntities, entityContentHash } from './records.js'
23
23
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
24
24
  import { buildFolderEntity } from './folder.js'
25
25
  import { siteProjectToDocument } from './site.js'
26
+ import { siteSelfScope, refuseOrgOption } from './self-scope.js'
26
27
  import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
27
28
  import { emitEntitySyncPackage } from './entity-document.js'
28
29
  import { isLocalAssetPath } from '../site/assets.js'
@@ -205,6 +206,9 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
205
206
  * @param {string} siteRoot - directory containing site.yml
206
207
  * @param {object} [opts]
207
208
  * @param {string} [opts.foundationDir] - local foundation root (collection Models)
209
+ * @param {string|null} [opts.scope] - the scope the site's `@/x` refs resolve
210
+ * into. Defaults to its foundation's (`siteSelfScope`) — the one in the
211
+ * foundation's name. `org` is refused: it carried the site owner's.
208
212
  * @param {Function} [opts.resolveModel] - async non-local Model resolver
209
213
  * @param {string} [opts.sourceLocale] - localized-field wrap locale
210
214
  * @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
@@ -271,6 +275,13 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
271
275
  const stamp = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {})
272
276
  const exporter = opts.exporter
273
277
  const exportedAt = opts.exportedAt
278
+ refuseOrgOption(opts, 'emitSyncPackages')
279
+ // ⭐ ONE SCOPE FOR THE WHOLE EMIT — the site's foundation's, the one `register`
280
+ // stored its Models under — so a record's `$model` and a query's `schema` are
281
+ // qualified alike. Read once here and handed to both. ⛔ It was the org that owns
282
+ // the SITE until 2026-09-22 (`self-scope.js`).
283
+ const scope =
284
+ opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
274
285
 
275
286
  const col = await buildRecordEntities(siteRoot, {
276
287
  // Which backend's record uuids go on the wire (sync.json) — see records.js.
@@ -278,10 +289,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
278
289
  ...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
279
290
  ...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
280
291
  ...(sourceLocale ? { sourceLocale } : {}),
281
- // The publish org — resolves a foundation-relative `@/x` model ref into
282
- // `@org/x` before it ships. Absent on an offline probe, which is why
283
- // buildRecordEntities warns rather than throws.
284
- ...(opts.org ? { org: opts.org } : {}),
292
+ // Resolves a foundation-relative `@/x` model ref into `@scope/x` before it ships.
293
+ // Null for a foundation with no scope yet, which is why buildRecordEntities
294
+ // warns rather than throws.
295
+ scope,
285
296
  })
286
297
  const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
287
298
 
@@ -325,10 +336,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
325
336
  // Whose $uuid belongs on this wire document — see siteProjectToDocument.
326
337
  ...(opts.backend ? { backend: opts.backend } : {}),
327
338
  ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
328
- // ⛔ THE SAME ORG `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
339
+ // ⛔ THE SAME SCOPE `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
329
340
  // must name the Model its records are stored under, and both are qualified
330
- // from one `@/x` by one rule (`./self-scope.js`) — so they take one org.
331
- ...(opts.org ? { org: opts.org } : {}),
341
+ // from one `@/x` by one rule (`./self-scope.js`) — so they take one scope.
342
+ scope,
332
343
  // Withhold the `$services`/`$secrets` Sections when the caller has
333
344
  // determined the file is not asking for anything new by them. Passed
334
345
  // through rather than decided here: the last-agreed state is project