Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b0375211 | ||
|
|
f07b4c73b1 | ||
|
|
bf3bd6c477 | ||
|
|
5c1e8d290e | ||
|
|
e32d77e5a1 | ||
|
|
dcf4220823 | ||
|
|
f5e3165ea5 | ||
|
|
ee8c593858 | ||
|
|
7ef620cda7 | ||
|
|
49b7943420 | ||
|
|
19e16bd44f | ||
|
|
3bd490c080 | ||
|
|
4b448109dd | ||
|
|
bc1149c15e | ||
|
|
089953a7cf | ||
|
|
7b25be22e9 | ||
|
|
4e3d179e61 | ||
|
|
ae58482b72 | ||
|
|
b2d40dada0 | ||
|
|
d30a4cce00 | ||
|
|
4cd280e48d | ||
|
|
8738a03893 | ||
|
|
04a01be992 | ||
|
|
812e2df1da | ||
|
|
8c292fb32f | ||
|
|
f45928c311 | ||
|
|
4d24ae8618 | ||
|
|
d790572e2e | ||
|
|
d7b1dd9601 | ||
|
|
0db2d19a22 | ||
|
|
8eb7e6354e | ||
|
|
9014a510a9 | ||
|
|
974e4740ab | ||
|
|
9cd6d39b71 | ||
|
|
143f925fd8 | ||
|
|
24294d3b77 | ||
|
|
24caeab057 | ||
|
|
888a6ad29b | ||
|
|
24462f460e | ||
|
|
a480ee83dc | ||
|
|
fd43ed5420 |
+8
-1
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"integration_trunk": "next",
|
||||
"release_branch": "main"
|
||||
"release_branch": "main",
|
||||
"flow": "trunk-release",
|
||||
"canonical_remote": "https://git.mosaicstack.dev/mosaicstack/stack",
|
||||
"canonical_clone": "host:/src/mosaic-stack",
|
||||
"worktree_root": "host:/src/mosaic-stack-worktrees",
|
||||
"worktree_policy": "orchestrator-precreated",
|
||||
"notes": "next=development/integration; main=production release. Never branch work off main. worktree_policy is TRANSITIONAL: the wrapper worktree consumer is BLOCKED on the J3/#1174 amendment (checked roots + capacity guard); pre-creation is the interim orchestration choice, not closed policy — it becomes a timing choice only after the wrapper can validate this root."
|
||||
}
|
||||
|
||||
+49
-4
@@ -38,10 +38,12 @@ when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
# This avoids from_secret which is blocked on pull_request events.
|
||||
# If the env vars aren't set, turbo falls back to local cache only.
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
||||
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
||||
# deployment — never pull_request). This PR pipeline deliberately gets no
|
||||
# remote-cache credentials: an untrusted PR must not be able to write to (or
|
||||
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
|
||||
# local cache only, which is the intended behavior here.
|
||||
|
||||
steps:
|
||||
install:
|
||||
@@ -91,6 +93,15 @@ steps:
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
# Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
|
||||
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
|
||||
# fail-closed: a seat whose login is missing gets a named error, never a
|
||||
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
|
||||
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
|
||||
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
|
||||
# repo. Pins that comment BODIES render on both paths and that a tea
|
||||
# failure is named as what it was (git-config vs credential).
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
|
||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||
# endpoints and ordinary commands through. Both directions are asserted —
|
||||
@@ -104,6 +115,40 @@ steps:
|
||||
# stub supplies the scale instead of the host's own checkout.
|
||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
||||
|
||||
# Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
|
||||
# .mosaic/repo.json is the machine-readable structure SSOT consumed by git
|
||||
# wrappers and the T32 gate seat; this is its repo-side CI enforcement.
|
||||
# Path-conditional: runs when the declaration, the vendored validator, or this
|
||||
# pipeline config changes (manual runs always include it). Fails the pipeline
|
||||
# on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
|
||||
# (--require-v2: edited/new declarations may not stay v1). The validator is
|
||||
# vendored into the framework tree (spec §5.1 final home) — provenance in its
|
||||
# header; the hostile-input suite (101 arms, hermetic) runs alongside so the
|
||||
# gate's own instrument ships in the same commit as the gate.
|
||||
structure-declaration:
|
||||
image: *node_image
|
||||
commands:
|
||||
- apk add --no-cache bash git
|
||||
# MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
|
||||
# for managed validation). CI has no host, so the step provisions an
|
||||
# EXPLICIT fixture root — honest configuration for the resolution path,
|
||||
# never a guess about a real host; the per-host containment checks are
|
||||
# runtime concerns and do not run against a fixture. Grammar, schema,
|
||||
# refs, flow, remote normalization, and path grammar all prove here.
|
||||
- mkdir -p /tmp/t51-ci-hostroot
|
||||
- bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
|
||||
- bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
|
||||
environment:
|
||||
MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include:
|
||||
- '.mosaic/repo.json'
|
||||
- 'packages/mosaic/framework/tools/structure/**'
|
||||
- '.woodpecker/ci.yml'
|
||||
- event: manual
|
||||
|
||||
# Canonical verify:release stage `upgrade-guard`.
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
|
||||
+216
-21
@@ -32,6 +32,11 @@ variables:
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
||||
# published gateway sha images with no web/appservice counterpart — no
|
||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
||||
# step now builds on next too (sha-only destinations, enforced per step).
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
@@ -44,16 +49,6 @@ variables:
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
@@ -73,6 +68,13 @@ steps:
|
||||
# being empty) and on any incomplete verification.
|
||||
verify:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||
@@ -108,6 +110,13 @@ steps:
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
@@ -202,6 +211,20 @@ steps:
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
# #1404: snapshot every publishable manifest BEFORE the transform so the
|
||||
# workspace can be restored byte-exact after publish. The transform
|
||||
# rewrites package.json in place (needed: pnpm publish reads the
|
||||
# workspace manifests); without restore, later steps in this pipeline
|
||||
# (build-gateway kaniko COPY + pnpm install --frozen-lockfile) see
|
||||
# manifests that no longer match pnpm-lock.yaml and fail
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE. Snapshot dir is step-local tmp.
|
||||
SNAPSHOT_DIR="$(mktemp -d /tmp/publish-next-manifests.XXXXXX)"
|
||||
export SNAPSHOT_DIR
|
||||
find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*" | while read -r mf; do
|
||||
mkdir -p "$SNAPSHOT_DIR/$(dirname "$mf")"
|
||||
cp -p "$mf" "$SNAPSHOT_DIR/$mf"
|
||||
done
|
||||
echo "[publish-next] snapshotted $(find "$SNAPSHOT_DIR" -name package.json | wc -l) manifests to $SNAPSHOT_DIR"
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
@@ -209,23 +232,38 @@ steps:
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
const exactVersions = new Map(); // name -> bumped next version
|
||||
|
||||
function walk(dir) {
|
||||
function walk(dir, visit) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
|
||||
visit(manifest, packagePath);
|
||||
}
|
||||
}
|
||||
walk(fullPath, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
// #1389: two passes. Pass 1 bumps every publishable manifest to
|
||||
// <stable+1>-next.<pipeline> exactly as before, recording name ->
|
||||
// bumped version. Pass 2 rewrites every published manifest's
|
||||
// @mosaicstack/* dependency entries (dependencies, devDependencies,
|
||||
// peerDependencies, optionalDependencies) to the EXACT same-pipeline
|
||||
// build. A caret range like ^0.0.3-next.2636 leaves the resolver free
|
||||
// to pick any later build — and on a host with a stale cache, an
|
||||
// installer-side scaffold pinned at stable, or a registry hiccup, that
|
||||
// freedom is how a "next" install ends up executing stable-era code
|
||||
// (web1 evidence: old tier validator, missing migrations). Exact pins
|
||||
// make the defect class unrepresentable regardless of resolver path.
|
||||
function bump(manifest, packagePath) {
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
@@ -234,13 +272,40 @@ steps:
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
exactVersions.set(manifest.name, manifest.version);
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
let pinnedEntries = 0;
|
||||
function pin(manifest, packagePath) {
|
||||
let changed = false;
|
||||
for (const field of DEP_FIELDS) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [name, range] of Object.entries(deps)) {
|
||||
if (!name.startsWith('@mosaicstack/')) continue;
|
||||
const exact = exactVersions.get(name);
|
||||
if (!exact) {
|
||||
throw new Error(
|
||||
manifest.name + ' depends on ' + name +
|
||||
' which has no bumped version in this publish set — cannot pin');
|
||||
}
|
||||
if (range === exact) continue;
|
||||
deps[name] = exact;
|
||||
pinnedEntries++;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root, bump);
|
||||
for (const root of roots) walk(root, pin);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
@@ -250,6 +315,94 @@ steps:
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
# #1389 post-publish guard: every freshly published manifest must carry
|
||||
# EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
|
||||
# fallback). A leak here fails the pipeline instead of shipping.
|
||||
node <<'GUARD'
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const published = [];
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
|
||||
}
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const root of roots) walk(root);
|
||||
let failures = 0;
|
||||
for (const name of published) {
|
||||
let manifest;
|
||||
try {
|
||||
const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
||||
const arr = JSON.parse(out);
|
||||
manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
|
||||
} catch (e) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
for (const field of fields) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [dep, range] of Object.entries(deps)) {
|
||||
if (!dep.startsWith('@mosaicstack/')) continue;
|
||||
const expected = dep === name ? manifest.version : null;
|
||||
const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
|
||||
const samePipeline = range.endsWith('-next.' + pipelineNumber);
|
||||
if (!isExactPin) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
|
||||
failures++;
|
||||
} else if (!samePipeline) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures > 0) {
|
||||
console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
|
||||
GUARD
|
||||
# #1404 restore: put the workspace manifests back byte-exact so later
|
||||
# steps (build-gateway frozen-lockfile install) see the committed tree.
|
||||
RESTORE_FAIL=0
|
||||
while read -r mf; do
|
||||
if [ -f "$SNAPSHOT_DIR/$mf" ]; then
|
||||
cp -p "$SNAPSHOT_DIR/$mf" "$mf"
|
||||
else
|
||||
echo "[publish-next] FATAL: no snapshot for $mf — cannot restore (snapshot incomplete?)" >&2
|
||||
RESTORE_FAIL=1
|
||||
fi
|
||||
done < <(find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*")
|
||||
# Pristine guard (#1404 red-first control): the publish step must leave
|
||||
# the workspace byte-identical to the checkout for every manifest.
|
||||
# git diff is the arbiter — any residual mutation fails THIS step
|
||||
# instead of surfacing as ERR_PNPM_OUTDATED_LOCKFILE in build-gateway.
|
||||
if ! git diff --exit-code -- '**/package.json' >/dev/null 2>&1; then
|
||||
echo "[publish-next] FATAL: workspace package.json files still differ from HEAD after restore (#1404 class)" >&2
|
||||
git diff --stat -- '**/package.json' >&2 || true
|
||||
RESTORE_FAIL=1
|
||||
fi
|
||||
rm -rf "$SNAPSHOT_DIR"
|
||||
if [ "$RESTORE_FAIL" -ne 0 ]; then exit 1; fi
|
||||
echo "[publish-next] workspace manifests restored byte-exact (git diff clean); later steps see the committed tree"
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
@@ -305,10 +458,18 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -322,8 +483,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next appservice publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
@@ -332,10 +502,18 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -349,8 +527,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next web publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
@@ -359,3 +546,11 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// The module under test imports @mosaicstack/db at module scope; we replace only the
|
||||
// pieces DatabaseModule uses (partial mock — the real module also exports the
|
||||
// schema the storage adapter's import chain needs) so the test pins the #1392
|
||||
// contract (refuse to start on an incomplete schema) without a live database.
|
||||
vi.mock('@mosaicstack/db', async (importOriginal) => {
|
||||
const actual: object = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
createDb: vi.fn(),
|
||||
createPgliteDb: vi.fn(),
|
||||
getMigrationStatus: vi.fn(),
|
||||
runPgliteMigrations: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { DatabaseModule } from './database.module.js';
|
||||
import { getMigrationStatus } from '@mosaicstack/db';
|
||||
import type { DbHandle } from '@mosaicstack/db';
|
||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
|
||||
function makeModule(storageType: 'postgres' | 'pglite', tier: string) {
|
||||
const storageAdapter = {
|
||||
name: storageType,
|
||||
migrate: vi.fn(),
|
||||
close: vi.fn(),
|
||||
} as unknown as StorageAdapter;
|
||||
const handle = { close: vi.fn() } as unknown as DbHandle;
|
||||
const config = {
|
||||
tier,
|
||||
storage: { type: storageType, url: 'postgresql://x' },
|
||||
} as unknown as MosaicConfig;
|
||||
return {
|
||||
mod: new DatabaseModule(handle, storageAdapter, config),
|
||||
storageAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DatabaseModule.onModuleInit — #1392 schema verification', () => {
|
||||
it('refuses to start when the postgres schema is incomplete', async () => {
|
||||
const { mod, storageAdapter } = makeModule('postgres', 'standalone');
|
||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
||||
appliedCount: 15,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_salty_morlocks',
|
||||
complete: false,
|
||||
});
|
||||
await expect(mod.onModuleInit()).rejects.toThrow('Database schema incomplete: 15/17');
|
||||
expect(storageAdapter.migrate).toHaveBeenCalled(); // migrations attempted first
|
||||
});
|
||||
|
||||
it('starts normally when the schema is complete', async () => {
|
||||
const { mod } = makeModule('postgres', 'standalone');
|
||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
||||
appliedCount: 17,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_salty_morlocks',
|
||||
complete: true,
|
||||
});
|
||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not verify postgres status for the local tier (PGlite migrates itself)', async () => {
|
||||
const { mod } = makeModule('pglite', 'local');
|
||||
vi.mocked(getMigrationStatus).mockClear();
|
||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
||||
expect(getMigrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
createDb,
|
||||
createPgliteDb,
|
||||
getMigrationStatus,
|
||||
runPgliteMigrations,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
@@ -74,6 +75,11 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
||||
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||
// avoid opening a second short-lived connection and doubling startup cost.
|
||||
//
|
||||
// #1392: we DO verify afterwards (getMigrationStatus opens one short-lived
|
||||
// connection) and refuse to start on an incomplete schema. A gateway that
|
||||
// boots "healthy" on an empty or partial database is precisely the failure
|
||||
// that shipped in the T63 batch: silent at startup, catastrophic later.
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (this.config.tier === 'local') {
|
||||
this.logger.log('Applying PGlite schema migrations...');
|
||||
@@ -81,6 +87,24 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
}
|
||||
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||
await this.storageAdapter.migrate();
|
||||
|
||||
if (this.config.storage.type === 'postgres') {
|
||||
const status = await getMigrationStatus(this.config.storage.url);
|
||||
if (!status.complete) {
|
||||
this.logger.error(
|
||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied ` +
|
||||
`(last expected: ${status.expectedLastTag}). ` +
|
||||
'Refusing to start on a partial schema — see issues #1392/#1402. ' +
|
||||
"Remediation: re-run 'mosaic gateway install' (it now verifies), or apply migrations manually.",
|
||||
);
|
||||
throw new Error(
|
||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Database schema verified: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
|
||||
@@ -14,10 +14,16 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
|
||||
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
|
||||
// every request body into a 400 at first use; this surfaces it at boot.
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Boot-time ValidationPipe metatype self-check (#1391).
|
||||
*
|
||||
* The check exists to fail loud at boot when the global pipe cannot see a
|
||||
* guarded DTO's decorated properties — the #436 class-erasure signature and
|
||||
* its dependency-graph cousins. Red/green arms:
|
||||
*
|
||||
* GREEN real module state: BootstrapSetupDto's three properties are
|
||||
* decorated and visible through the globalThis-shared storage.
|
||||
* RED a control class with NO decorators (the erasure shape): the
|
||||
* check throws PipeMetatypeCheckError naming every property.
|
||||
* RED-2 a control where one property is decorated and two are not: the
|
||||
* error names exactly the missing two — the miss list is precise,
|
||||
* not a blanket failure.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IsString } from 'class-validator';
|
||||
import {
|
||||
assertValidationPipeSeesDtoDecorators,
|
||||
PipeMetatypeCheckError,
|
||||
} from './validation-pipe-check.js';
|
||||
|
||||
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
|
||||
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
|
||||
// Simulate metatype erasure: an undecorated class standing where a
|
||||
// decorated DTO should be. Redefine the guard table for the test by
|
||||
// importing the module and pointing its table at the eroded class —
|
||||
// the check reads the table at call time, so a fresh module instance
|
||||
// with a swapped table reproduces the boot failure deterministically.
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class ErodedDto {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
expect(original).toBeDefined();
|
||||
// Swap in the eroded target (same declared properties, zero decorators).
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'ErodedDto',
|
||||
target: ErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('ErodedDto.name');
|
||||
expect(message).toContain('ErodedDto.email');
|
||||
expect(message).toContain('ErodedDto.password');
|
||||
}
|
||||
} finally {
|
||||
// Restore real module state for any later test in this file.
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
// And confirm the restore is real.
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class HalfErodedDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'HalfErodedDto',
|
||||
target: HalfErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
expect.unreachable('partially decorated DTO must fail the boot check');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('HalfErodedDto.email');
|
||||
expect(message).toContain('HalfErodedDto.password');
|
||||
expect(message).not.toContain('HalfErodedDto.name has no');
|
||||
}
|
||||
} finally {
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
* decorated properties of the DTOs it guards (#1391, #436 class).
|
||||
*
|
||||
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
|
||||
* `import type` class erasure (#436), or a dependency graph where the
|
||||
* controller's decorators and the application's route enhancers disagree
|
||||
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
|
||||
* ValidationPipe's whitelist treats every property as forbidden. The first
|
||||
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
|
||||
* worst place to discover wiring damage: the operator cannot tell a broken
|
||||
* payload from a broken daemon.
|
||||
*
|
||||
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
|
||||
* decorated properties, the gateway refuses to start with a named cause.
|
||||
* It catches the whole class — erasure, decorator metadata loss — on every
|
||||
* host, at the moment the damage exists rather than at first use.
|
||||
*
|
||||
* Storage sharing note: class-validator keys its metadata storage on
|
||||
* globalThis, so duplicate package copies do NOT hide metadata (measured,
|
||||
* #1391 diagnosis). What hides it is losing the metatype itself, which is
|
||||
* what this asserts against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DTOs the global pipe guards, mapped to the properties the whitelist must
|
||||
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
|
||||
* decorators register metadata keyed on the constructor, and its executor
|
||||
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
|
||||
* through `prototype` returns zero. Extend when adding DTOs to the app.
|
||||
*/
|
||||
export const PIPE_GUARDED_DTOS: Array<{
|
||||
name: string;
|
||||
target: abstract new (...args: never[]) => unknown;
|
||||
properties: string[];
|
||||
}> = [
|
||||
{
|
||||
name: 'BootstrapSetupDto',
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
constructor(missing: string[]) {
|
||||
super(
|
||||
'ValidationPipe metatype check failed: ' +
|
||||
missing.join('; ') +
|
||||
'. The global ValidationPipe cannot see decorated DTO properties — ' +
|
||||
'every request body would be rejected as non-whitelisted. ' +
|
||||
'Check for import-type erasure or decorator metadata loss in the ' +
|
||||
'dependency graph (see issues #436, #1391).',
|
||||
);
|
||||
this.name = 'PipeMetatypeCheckError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the pipe's whitelist can see every guarded DTO's decorated
|
||||
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
|
||||
* each miss. Pure function of module state: no I/O, safe to call twice.
|
||||
*/
|
||||
export function assertValidationPipeSeesDtoDecorators(): void {
|
||||
const storage = getMetadataStorage();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const dto of PIPE_GUARDED_DTOS) {
|
||||
// class-validator records constraints keyed on the DTO's constructor
|
||||
// (decorators run on the class), and its executor resolves them via
|
||||
// object.constructor. A property with no recorded metadata is invisible
|
||||
// to the whitelist — whatever the cause — and fails here.
|
||||
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
|
||||
// strictGroups, groups?). No schema, always=true, no groups: every
|
||||
// constraint regardless of grouping, which is what the whitelist sees.
|
||||
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
|
||||
const decorated = new Set(metadatas.map((m) => m.propertyName));
|
||||
|
||||
for (const property of dto.properties) {
|
||||
if (!decorated.has(property)) {
|
||||
missing.push(
|
||||
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new PipeMetatypeCheckError(missing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'reflect-metadata';
|
||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { TeamsController } from './teams.controller.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
||||
|
||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
||||
|
||||
const teamsServiceMock = {
|
||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
||||
findAllForUser: vi.fn((userId: string) =>
|
||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
||||
),
|
||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
||||
isMember: vi.fn((teamId: string, userId: string) =>
|
||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
||||
),
|
||||
};
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
||||
requestContext.user = currentUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('teams endpoints are scoped to membership', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUser = { id: 'user-1' };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha]);
|
||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns every team for an admin', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(teamAlpha);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
||||
expect(denied.status).toBe(403);
|
||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: false });
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('an admin can look up any membership', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: true });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,68 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
type RequestUser = { id: string; role?: string };
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
if (user.role === 'admin') {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
return this.teams.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
return this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
async checkMembership(
|
||||
@Param('teamId') teamId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
// A user may always ask about their own membership; anything else is
|
||||
// team-scoped like the other routes.
|
||||
if (userId !== user.id) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-scoped access: admins see any team; everyone else only teams they
|
||||
* are a member of. NotFoundException when the team does not exist and
|
||||
* ForbiddenException when the user lacks access (same convention as the
|
||||
* projects controller).
|
||||
*/
|
||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
||||
const team = await this.teams.findById(teamId);
|
||||
if (!team) throw new NotFoundException('Team not found');
|
||||
if (user.role === 'admin') return team;
|
||||
const isMember = await this.teams.isMember(teamId, user.id);
|
||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +56,21 @@ export class TeamsService {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* List only the teams the user is a member of.
|
||||
*/
|
||||
async findAllForUser(userId: string) {
|
||||
const memberRows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
if (teamIds.length === 0) return [];
|
||||
|
||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Administrator Guide
|
||||
|
||||
> **Status:** Partially migrated. Current SSO and local upgrade/recovery procedures are available; held procedures are labeled non-operative.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Administrator Operations
|
||||
|
||||
> **Status:** Partially migrated. Procedures explicitly identify whether they are current or held.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mos Connector Lease Operations — M1
|
||||
|
||||
> **Status:** Held / non-operative.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Upgrade safety and recovery
|
||||
|
||||
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Security
|
||||
|
||||
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Discord ingress security
|
||||
|
||||
> **Status:** Current Discord behavior only. Telegram shared-contract parity, Matrix channel ingress, and a gateway-wide shared adapter registry are not implemented or are not proven by the current source/tests.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
title: SSO Providers
|
||||
type: runbook
|
||||
audience: admin
|
||||
status: current
|
||||
source_of_truth: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# API Documentation
|
||||
|
||||
> **Status:** Scaffold only. The canonical gateway contract has not yet been migrated into this directory.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Developer Guide
|
||||
|
||||
> **Status:** Partially migrated. Architecture, lease-broker verification, and channel-adapter authoring pages are current; other contributor chapters remain unmigrated.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
> **Status:** Partially migrated. The lease-broker security-contract pages below are current references; the remaining architecture pages are still being classified.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Channel protocol architecture
|
||||
|
||||
> **Status:** Current shared type contract and Discord compatibility baseline. The shared gateway registry, Telegram parity, Matrix integration, identity-linking, and multi-surface multiplexing described below are draft or unimplemented.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Compaction observer revocation and runtime generations
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Architecture Decisions
|
||||
|
||||
> **Status:** Current decision index. A decision describes an implemented and accepted boundary; draft proposals belong under `rfcs/` or `docs/plans/`.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||
|
||||
> **Decision status:** Current implemented decision (M1).
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Authenticated external lease broker protocol
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# WI-1 lease broker security notes
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Whole mutator-class lease gate
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Architecture RFCs
|
||||
|
||||
> **Status:** Current proposal index. RFCs are draft design material and have no operational or implementation authority until an approved decision and implementation evidence supersede them.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# RFC: Optional AI Egress Gateways
|
||||
|
||||
> **Status:** Draft / proposed — not approved, not current, and not integrated.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Channel adapters
|
||||
|
||||
> **Status:** Current shared channel types plus the Discord reference/compatibility implementation. A shared gateway adapter registry, Telegram parity, and Matrix channel integration remain unimplemented or unproven.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
title: Lease-broker operations
|
||||
type: runbook
|
||||
audience: developer
|
||||
status: current
|
||||
source_of_truth: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: tracking
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mission Manifest — MVP
|
||||
|
||||
> Top-level rollup tracking Mosaic Stack MVP execution.
|
||||
|
||||
+247
-1014
File diff suppressed because it is too large
Load Diff
+44
-8
@@ -1,3 +1,9 @@
|
||||
---
|
||||
kind: spec
|
||||
source_of_truth: true
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mosaic Stack Documentation
|
||||
|
||||
This directory is the canonical home for Mosaic Stack product, architecture, API, operations, and delivery documentation.
|
||||
@@ -146,21 +152,51 @@ Every canonical page should:
|
||||
7. Include an owner or maintenance responsibility for operationally sensitive content.
|
||||
8. Link to the relevant book index and related canonical pages.
|
||||
|
||||
Recommended front matter for canonical pages:
|
||||
Required front matter for every canonical page:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Human-readable page title
|
||||
type: guide
|
||||
audience: developer
|
||||
status: current
|
||||
source_of_truth: false
|
||||
kind: tracking | projection | spec | guide | record | superseded
|
||||
status: active # or: completed | superseded-by: <path>
|
||||
source_of_truth: false # optional, defaults false
|
||||
audience: developer # optional: user | admin | developer | all
|
||||
title: Human-readable page title # optional
|
||||
---
|
||||
```
|
||||
|
||||
Allowed `type` values include `guide`, `concept`, `reference`, `decision`, `rfc`, and `runbook`. Allowed `audience` values are `user`, `admin`, `developer`, and `all`. Allowed `status` values are `current`, `draft`, `deprecated`, and `historical`.
|
||||
`kind` says what the document **is**. One value, required, and it follows the document's content,
|
||||
never its filename: a file named `TASKS.md` whose body says "this is a build plan, not a task
|
||||
tracker" is a `spec`.
|
||||
|
||||
Indexes may omit front matter when their purpose is self-evident. A page with normative authority must explicitly identify the authority it owns and the boundaries of that authority.
|
||||
| kind | rule |
|
||||
| ---------- | ---------------------------------------------------------------- |
|
||||
| tracking | Live state, single-writer. Never a spec |
|
||||
| projection | Generated. Never hand-edited. MUST have a drift test |
|
||||
| spec | How to build one goal or workstream |
|
||||
| guide | Explains use. Decides nothing |
|
||||
| record | What happened. Never authoritative, never updated after the fact |
|
||||
| superseded | Kept for history, and NAMES its replacement |
|
||||
|
||||
`source_of_truth` is a separate boolean because authority is **orthogonal to kind**. A document can
|
||||
be a `spec` and still be the thing everything else answers to;
|
||||
`docs/requirements/native-kanban-sot.md` is exactly that. Folding authority into `kind` forced one
|
||||
field to carry two independent facts, which is why an earlier draft of this contract could not
|
||||
classify that file at all.
|
||||
|
||||
`status` has three values. `active` means in force. `completed` means the work the document
|
||||
describes landed and the document is now finished rather than stale; executed implementation plans
|
||||
take this. `superseded-by: <path>` replaces `status` entirely and names the replacement.
|
||||
|
||||
**This contract covers `.md` files only.** It is not an omission: a YAML document cannot carry YAML
|
||||
front matter. The repository's own `[email protected]` throws `Source contains multiple documents` on a
|
||||
front-mattered `.yaml`, and `parseNorthStar` (`packages/mosaic/src/commands/fleet.ts:242`) is a live
|
||||
consumer that would break. `.yaml` sources declare their own kind inside the document or not at all.
|
||||
|
||||
A `parent` field is planned and is deliberately not yet required; it lands once the docs flatten
|
||||
settles the paths it would point at.
|
||||
|
||||
Indexes may omit front matter when their purpose is self-evident. A page with normative authority
|
||||
must explicitly identify the authority it owns and the boundaries of that authority.
|
||||
|
||||
## Obsidian and link conventions
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mosaic Stack Roadmap
|
||||
|
||||
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
|
||||
phase appears here from day one, even as a placeholder** — nothing exists only
|
||||
in heads. A phase marked _placeholder_ is a commitment to design it, not a
|
||||
design; scoping one requires its own PRD section or requirements doc plus
|
||||
review.
|
||||
|
||||
Phases are product phases. The in-flight platform workstreams (KBN-100/101
|
||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
|
||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
||||
and are prerequisites where noted.
|
||||
|
||||
| Phase | Scope | Status |
|
||||
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
|
||||
| P1 | **v1 slice** (PRD Part I §9) | next up |
|
||||
| P2 | Connectors + comms + wizard expansion | placeholder |
|
||||
| P3 | Full onboarding profile + M365 | placeholder |
|
||||
| P4 | Enterprise mode + one-way conversion | placeholder |
|
||||
| P5 | Federation | placeholder (deliberately undesigned, D3) |
|
||||
|
||||
## P0 — current state
|
||||
|
||||
What exists on `next` today: web dashboard (login/register/SSO, chat,
|
||||
read-only projects/tasks, settings, admin user/system-health tabs), the
|
||||
Gateway, the CLI-first framework tooling, and the fleet control plane. The
|
||||
webUI audit (USC estate, webui-audit lane) measures the gap between this and
|
||||
P1.
|
||||
|
||||
## P1 — v1 slice (D11)
|
||||
|
||||
1. Standalone onboarding wizard: system/company name, component choices,
|
||||
initial user, initial estate + project, seeded examples, re-runnable.
|
||||
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
|
||||
task bubble-up (kanban SOT Amendment A1 is the schema contract).
|
||||
3. Basic RBAC on the hierarchy.
|
||||
4. Minimal agent enrollment: one harness, API key, name/persona.
|
||||
|
||||
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
|
||||
webUI→tool mapping (any missing tool is built first, D12).
|
||||
|
||||
## P2 — connectors + comms + wizard expansion (placeholder)
|
||||
|
||||
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
|
||||
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
|
||||
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
|
||||
the D4 capabilities deferred out of P1's minimal slice: expanded agent
|
||||
enrollment (OAuth login, multi-account, model choice with recommendation,
|
||||
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
|
||||
configuration tab.
|
||||
|
||||
## P3 — full onboarding profile + M365 (placeholder)
|
||||
|
||||
Complete user onboarding profile (communication-style capture, optional
|
||||
voice-matching interview) under the D14 custody rule; M365 connectors,
|
||||
available to both deployment modes as ordinary connectors (same consent model
|
||||
as the P2 connector class). The Enterprise install flow's M365 prominence
|
||||
(D4) arrives with the Enterprise phase, P4.
|
||||
|
||||
## P4 — Enterprise mode + conversion (placeholder)
|
||||
|
||||
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
|
||||
prominent); per-user brains with architectural isolation (D14); Vault
|
||||
required; the one-way Standalone → Enterprise conversion (D3).
|
||||
|
||||
## P5 — federation (placeholder)
|
||||
|
||||
Connecting deployments: system-level config, assigned users, rights and
|
||||
data-access control, trusts with boundaries, strict data access, exfiltration
|
||||
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
|
||||
foreclose it. Requires its own PRD + threat model before any scoping.
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# User Guide
|
||||
|
||||
> **Status:** Partially migrated. The quickstart, web-dashboard reference, and Discord conversation workflow are current.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
title: Mosaic Stack Quickstart
|
||||
type: guide
|
||||
audience: user
|
||||
status: current
|
||||
source_of_truth: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
title: Mosaic web dashboard
|
||||
type: guide
|
||||
audience: user
|
||||
status: current
|
||||
source_of_truth: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Discord conversations
|
||||
|
||||
> **Status:** Current Discord workflow for an administrator-provisioned, authorized guild channel.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: tracking
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mission Manifest — Federation v1
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
||||
|
||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# PRD — Fleet Phase 2: Operator Observability
|
||||
|
||||
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Desired, Derived, and Observed Fleet State
|
||||
|
||||
## One writable authority
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Generated Environment Launch Chain
|
||||
|
||||
The launcher consumes validated data, not shell configuration.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Fleet Identity, Class, and Runtime
|
||||
|
||||
Each roster field has one job. Do not use names or model strings as authority shortcuts.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Fleet Role Authority and Leases
|
||||
|
||||
Role content describes behavior; protected authority is immutable code metadata derived only from the canonical class.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Configure an Interaction Instance
|
||||
|
||||
An interaction instance is a configurable local roster member with canonical class: interaction and matching tool_policy: interaction. “Tess” may be used as a display alias, but neither that alias nor the stable name is required or authority-bearing.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Configure a Validator Instance
|
||||
|
||||
A validator instance is a configurable local roster member with canonical class: validator and matching tool_policy: validator. “Ultron” may be used as a display alias, but it is not a required identity, class alias, product name, or source of authority.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Create, Inspect, Update, and Delete a Local Fleet Agent
|
||||
|
||||
Use the local roster-v2 control plane only. These commands change desired state and derived environment projections; they never start, stop, reconcile, inspect, or otherwise act on systemd, tmux, sessions, or runtimes.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Customize Fleet Roles
|
||||
|
||||
Mosaic resolves persona contracts through two layers:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Safely Reconcile and Control a Local Fleet Agent
|
||||
|
||||
Use the canonical local roster-v2 command surface:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Executable Fleet Example, Profile, and Service-Preset Dispositions
|
||||
|
||||
**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Legacy Fleet Class Aliases
|
||||
|
||||
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Previewing a Fleet Roster v1-to-v2 Migration
|
||||
|
||||
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Fleet Configuration Backup and Restore Boundary
|
||||
|
||||
**Issue:** #758 · **Card:** FCM-M4-001
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Environment Quarantine Operations
|
||||
|
||||
Legacy <name>.env is input evidence, never current launch authority. Projection preparation classifies it deterministically:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Reconcile and Recover a Local Fleet
|
||||
|
||||
## Safe sequence
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Systemd and tmux Troubleshooting
|
||||
|
||||
Start with read-only mosaic fleet status, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Upgrade and Installed-Asset Drift
|
||||
|
||||
Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Local Fleet Agent Mutations
|
||||
|
||||
FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Fleet Control-Plane CLI
|
||||
|
||||
The local desired-state surface is mosaic fleet. It is distinct from the gateway-backed mosaic agent catalog and from legacy compatibility commands that act on roster v1.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Fleet Generated Environment Boundary
|
||||
|
||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Local Fleet Lifecycle Transitions
|
||||
|
||||
Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Fleet Role Classes and Authority
|
||||
|
||||
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Fleet Roster v2 Structural Contract
|
||||
|
||||
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Local Fleet Status and Drift
|
||||
|
||||
mosaic fleet status [<name>], `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Deployment Guide
|
||||
|
||||
> **Status: non-operative for PostgreSQL, federated, and bare-metal production.** The checked-in
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mosaic Stack — Developer Guide
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Local Fleet Canary
|
||||
|
||||
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Migrating to the Federated Tier
|
||||
|
||||
> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: tracking
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
||||
|
||||
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices
|
||||
|
||||
**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# CI Queue Guard Purpose Semantics
|
||||
|
||||
- **Issue:** #1146
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: completed
|
||||
---
|
||||
|
||||
# CI Queue Guard Purpose Semantics Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# Documentation Catalog and Truth Audit Plan
|
||||
|
||||
**Task:** DOCS-IA-002
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# Documentation Information Architecture Design
|
||||
|
||||
**Status:** Approved
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Documentation Structure README Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
---
|
||||
kind: tracking
|
||||
status: active
|
||||
---
|
||||
|
||||
# W4 — document contract worklist
|
||||
|
||||
Companion to `2026-08-20_stack-docs-flatten-and-alignment.md`. That document proposes the
|
||||
contract; this one records what was applied, what was held, and what still needs a decision.
|
||||
|
||||
Measured on `origin/next` at `63069149`. Author: veronica. Review: fred (Gate-16, author is
|
||||
not reviewer), then a pi seat for the adversarial pass.
|
||||
|
||||
## What "live" means here
|
||||
|
||||
All `*.md` under `docs/`, minus `docs/archive/`, minus `docs/_old_structure/`. That is **127**
|
||||
files. The flatten plan says 130; the arithmetic does not close (318 total = 134 archive +
|
||||
57 `_old_structure` + 127 live, not 130).
|
||||
|
||||
## Applied
|
||||
|
||||
**These are the FIRST-PASS counts and they are superseded. The current tree is counted in
|
||||
"Verification arithmetic, re-closed" at the end of this document.** They are left standing rather
|
||||
than overwritten, for the same reason every other correction here is: a record of what a pass
|
||||
produced is worth more than a number silently updated to still look right.
|
||||
|
||||
| bucket | count | note |
|
||||
| --------------------------- | ----- | -------------------------------------- |
|
||||
| stamped `kind` + `status` | 107 | this pass |
|
||||
| held, operator judgement | 17 | section "Needs a decision" below |
|
||||
| held, cites the moving path | 3 | the three `SUPERSEDED` TASKS.md stamps |
|
||||
| held, generated file | 1 | `docs/fleet/NORTH_STAR.md`, see below |
|
||||
|
||||
128 live `.md` under `docs/`, which is the 127 baseline plus this document. 107 + 17 + 3 + 1 = 128.
|
||||
|
||||
Kinds: 53 `guide`, 34 `record`, 13 `spec`, 7 `tracking`. Status: 105 `active`, 2 `completed`.
|
||||
|
||||
After fred's Q1 ruling stamped `docs/README.md`, one file moved from the held bucket to the stamped
|
||||
one and nothing else changed: **108 stamped + 16 + 3 + 1 = 128**, kinds 54 `guide`, 34 `record`,
|
||||
13 `spec`, 7 `tracking`, status 106 `active`, 2 `completed`. Two files carry
|
||||
`source_of_truth: true`, `docs/README.md` and `docs/requirements/native-kanban-sot.md`.
|
||||
|
||||
`parent` is **not** applied. It points at `docs/fleet/NORTH_STAR.yaml`, which the flatten moves
|
||||
to `docs/NORTH_STAR.yaml`. Stamping it now means re-pointing 127 files by hand later. It lands
|
||||
after the move, in one pass, with the post-move path.
|
||||
|
||||
## Held: the three superseded stamps
|
||||
|
||||
`docs/TASKS.md:5`, `docs/federation/TASKS.md:5`, and `docs/fleet/TASKS.md:5` each carry a W1
|
||||
stamp of the form:
|
||||
|
||||
> **STATUS: SUPERSEDED — 2026-08-20.** kind `tracking` · superseded by `docs/fleet/NORTH_STAR.yaml`
|
||||
|
||||
Two problems, both real:
|
||||
|
||||
1. **The tooling cannot read them.** That line is a blockquote below the H1, not YAML front
|
||||
matter. Plan section 6 check 5 ("every live document has a header; no document is
|
||||
unclassified") parses front matter, so all three read as unclassified. The control that the
|
||||
parse itself works is `docs/fleet/FLEET-DOCTRINE.md:3`, which is genuine front matter.
|
||||
2. **They cite the moving path.** Three of the six citations of `docs/fleet/NORTH_STAR.yaml`
|
||||
are these stamps. Converting them before the flatten lands makes them stale on merge.
|
||||
|
||||
Converted after the flatten, not before.
|
||||
|
||||
## Held: the one generated file
|
||||
|
||||
`docs/fleet/NORTH_STAR.md` is a `projection` and was stamped in the first pass. The stamp was
|
||||
**reverted before review**, because stamping it is self-contradictory in a way that is not
|
||||
merely theoretical:
|
||||
|
||||
- The contract says a `projection` is "Generated. Never hand-edited." Adding front matter by
|
||||
hand is a hand edit of a generated file, and the file's own banner says
|
||||
"**Generated file — do not edit by hand.**"
|
||||
- `renderNorthStarMarkdown()` at `packages/mosaic/src/commands/fleet.ts:373` emits the H1 as
|
||||
its first line and no front matter at all.
|
||||
- `fleet-north-star.spec.ts:110-114` asserts full-string equality between the renderer's output
|
||||
and the committed file: `expect(rendered).toBe(committed)`. Before the stamp, line 1 of the
|
||||
committed file was `# Mosaic Fleet — NORTH STAR`, matching the renderer. After it, line 1 was
|
||||
`---`. The assertion fails.
|
||||
|
||||
So the header for a projection cannot live in the file. It has to be emitted by
|
||||
`renderNorthStarMarkdown()`, which is a code change and belongs in the flatten PR alongside the
|
||||
`resolveNorthStarPaths()` fix, not in a documentation pass.
|
||||
|
||||
This generalises: **the contract as written cannot classify any generated document without a
|
||||
matching change to its generator.** `NORTH_STAR.md` is the only projection today, so the cost
|
||||
is one function. It will not stay one.
|
||||
|
||||
## Needs a decision
|
||||
|
||||
Nineteen rows. Seventeen are the plan's section 9 list, minus `docs/fleet/north-star.md`
|
||||
(renamed to `FLEET-DOCTRINE.md` by W1, so the row is closed) and minus the two `.yaml` rows,
|
||||
which are a different problem — see the next section. Two rows are new, found while
|
||||
classifying.
|
||||
|
||||
Fill the `kind` column with a value from the contract, or `superseded-by: <path>`.
|
||||
|
||||
| path | proposed | why it is not mechanical |
|
||||
| --------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `docs/README.md` | `guide` | It also **prescribes** the competing front-matter convention (see below). Whatever kind it gets, its body needs an edit. |
|
||||
| `docs/SITEMAP.md` | `guide` or `projection` | If it is generated from the tree it is a projection and needs a drift test. If hand-maintained it is a guide that goes stale silently. Nobody has said which. |
|
||||
| `docs/federation/SETUP.md` | `guide` | Reads as a guide. Federation tier status is the open part: if the tier is shelved this is `superseded`. |
|
||||
| `docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md` | `record` | An acceptance checklist is evidence of a past gate, which is `record`. But if it is still being checked against, it is `tracking`. |
|
||||
| `docs/fleet/FLEET-LAUNCH.md` | `guide` | Runbook. Depends on whether `launch-seat.sh` is deprecated in favour of `mosaic fleet` (campaign W3). If so, `superseded`. |
|
||||
| `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` | `record` | An inventory of dispositions taken. `record` unless dispositions are still pending, which the title implies they were once. |
|
||||
| `docs/fleet/README.md` | `guide` | Section index. Low risk; listed only because the plan lists it. |
|
||||
| `docs/fleet/backlog-conventions.md` | `guide` | Conventions decide things, and `guide` explicitly decides nothing. May be `spec`. |
|
||||
| `docs/fleet/f4-matrix-connector.md` | `spec` | F4 is a workstream. Whether it is live or abandoned decides `spec` versus `superseded`. |
|
||||
| `docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md` | `record` | Same question as the fleet IA checklist. |
|
||||
| `docs/native-kanban-sot/INDEX.md` | `guide` | Index of the canon. If it is generated from the canon it is a projection. |
|
||||
| `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` | `spec` | 415 lines of normative gate. `spec` fits; confirm it is not superseded by the shared contract. |
|
||||
| `docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md` | `spec` | As above. |
|
||||
| `docs/native-kanban-sot/KBN-101-ENVELOPE-A.md` | `spec` | Title says "v6, FINAL". If v6 supersedes v1-v5 elsewhere, those need `superseded-by` pointing here. |
|
||||
| `docs/native-kanban-sot/SHARED-CONTRACT.md` | `spec` | "Remediated Shared Contract v1". Same versioning question. |
|
||||
| `docs/release-integrity/probe-inventory.md` | `record` | An inventory. `tracking` if probes are still being added to it. |
|
||||
| `docs/webui/PHASE-P-STRUCTURE.md` | `spec` | Zero inbound references (plan section 5.4). Either wire it in or mark it superseded; the kind is the smaller question. |
|
||||
| `docs/native-kanban-sot/TASKS.md` | **conflict** | The file says of itself: "This file is a build plan, not a task tracker, and is NOT superseded." The contract says `tracking` is "live state, single-writer (manifests, `TASKS.md`). **Never a spec**." The file claims to be the thing its name forbids. Either the file is wrong or the contract's `TASKS.md` shorthand is. |
|
||||
| `docs/requirements/native-kanban-sot.md` | **conflict** | Plan section 5.2 says if it is hand-authored canon it is `source-of-truth`. The contract's own table says `source-of-truth` is **machine-readable**. This is prose markdown with normative MUST/MUST NOT, RATIFIED 2026-07-14, decision owner Jason. It cannot satisfy both rules. Either it is a `spec` or the machine-readable criterion is wrong. |
|
||||
|
||||
The last two are not slow rows, they are contradictions inside the contract. They want an
|
||||
answer about the contract, not about the file.
|
||||
|
||||
## The contract collides with an existing one
|
||||
|
||||
`docs/README.md` lines 150-160 already document a front-matter convention, with its own
|
||||
allowed values:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Human-readable page title
|
||||
type: guide # guide | concept | reference | decision | rfc | runbook
|
||||
audience: developer # user | admin | developer | all
|
||||
status: current # current | draft | deprecated | historical
|
||||
source_of_truth: false
|
||||
---
|
||||
```
|
||||
|
||||
Adoption is 4 of 127 files: `docs/ADMIN-GUIDE/security/sso-providers.md`,
|
||||
`docs/DEVELOPER-GUIDE/testing/lease-broker-operations.md`,
|
||||
`docs/USER-GUIDE/getting-started/quickstart.md`, `docs/USER-GUIDE/product/web-dashboard.md`.
|
||||
|
||||
`status` is in both schemas with **disjoint vocabularies**. `type` and `kind` are two names for
|
||||
one idea with different value sets. `source_of_truth: false` is a boolean spelling of
|
||||
`kind: source-of-truth`.
|
||||
|
||||
What this pass did, and it is a decision someone should ratify or reverse: the new contract
|
||||
wins. Those 4 files had `status: current` rewritten to `status: active` and gained `kind:`.
|
||||
Their `title`, `type`, `audience` and `source_of_truth` keys were left alone. Nothing reads any
|
||||
of them — `git grep source_of_truth` outside `docs/` returns zero hits — so no consumer broke.
|
||||
|
||||
`docs/README.md` still prescribes the old convention. It is an operator row above, so this pass
|
||||
did not edit it. Until it is edited, the repository documents two conflicting header
|
||||
conventions and points authors at the one being retired.
|
||||
|
||||
## The contract has no form for a YAML document
|
||||
|
||||
Two of the plan's 20 rows are not markdown: `docs/fleet/examples/roster-v2.yaml` and
|
||||
`docs/openapi-tess.yaml`. Front matter is a markdown convention. A `.yaml` file can carry a
|
||||
leading `---` document, but then it is two YAML documents and every existing parser of that
|
||||
file sees a change.
|
||||
|
||||
This is not an edge case. It applies to **`docs/fleet/NORTH_STAR.yaml`, the source of truth
|
||||
itself**, which is the one file the contract most needs to classify. Section 6 check 5 says no
|
||||
document is unclassified. As written, the source of truth cannot comply.
|
||||
|
||||
Options, none of them chosen here: exclude `.yaml` from the contract and say so; carry their
|
||||
metadata in a sidecar; or add a top-level `kind:` key inside the YAML body rather than as front
|
||||
matter, which for `NORTH_STAR.yaml` is a schema change.
|
||||
|
||||
## Method, and what it cannot tell you
|
||||
|
||||
Classification is per-file, by title and path, recorded with a confidence. It is not a regex
|
||||
sweep. The plan's own warning stands and is why the 19 rows above are held rather than guessed:
|
||||
the first classifier pass classed a RATIFIED requirements document as a projection.
|
||||
|
||||
Rows marked `med` in the working manifest and not listed above: the five `docs/plans/*` specs,
|
||||
`docs/fleet/migration/example-profile-disposition.md`, the one ADR
|
||||
(`decisions/mos-runtime-portability-m1.md`, called `record` because an ADR records a decision
|
||||
taken), and the one RFC (`rfcs/optional-ai-egress-gateways.md`, called `spec` because it
|
||||
proposes work not yet built). Those eight are stamped and are the most likely to be wrong.
|
||||
|
||||
## Verification
|
||||
|
||||
- 103 of 103 files parse with the expected `kind` and `status` in front matter.
|
||||
- The check was shown to reject a wrong kind before it was trusted (asserting
|
||||
`kind: record` on a file stamped `guide` fails).
|
||||
- The whole diff removes 4 lines, all of them `status: current`.
|
||||
- 24 files untouched, matching 19 + 3 + 1 + 1.
|
||||
|
||||
## Response to the W5 adversarial pass
|
||||
|
||||
vision ran a refute-first pass on this branch at `37cd00e` from a fresh worktree. Three of its
|
||||
points changed the branch. Everything is re-measured here before being acted on; where my
|
||||
measurement disagrees with its stated evidence I say so.
|
||||
|
||||
### 1. `docs/fleet/NORTH_STAR.md` stamp reverted (`bea47543`)
|
||||
|
||||
vision raised this as **latent** and scoped to the flatten PR. It is **live in this PR**, so it
|
||||
could not wait.
|
||||
|
||||
`fleet-north-star.spec.ts:110-114` is a drift test that reads the committed file from disk and
|
||||
asserts full-string equality against `renderNorthStarMarkdown()`, whose first emitted line is the
|
||||
H1 and which emits no front matter. Stamping changed line 1 from the H1 to `---`.
|
||||
|
||||
CI 2589 confirms it directly, no longer by construction:
|
||||
|
||||
```
|
||||
× renderNorthStarMarkdown > matches the committed NORTH_STAR.md projection (regenerate if this fails)
|
||||
→ expected '# Mosaic Fleet — NORTH STAR\n\n> **Ge…' to be '---\nkind: projection\nstatus: active…'
|
||||
```
|
||||
|
||||
Reverted to `origin/next` verbatim. `git diff origin/next -- docs/fleet/NORTH_STAR.md` is 0 lines;
|
||||
control on `docs/fleet/reference/cli.md` returns 13, so the diff command does report differences.
|
||||
|
||||
The consequence is a contract-level one, recorded in the worklist: **the contract as written
|
||||
cannot classify any generated document without a matching change to its generator.** A `projection`
|
||||
is "Generated. Never hand-edited," so its header has to come out of the renderer. That is a code
|
||||
change and belongs in the flatten PR beside the `resolveNorthStarPaths()` fix. vision's
|
||||
recommendation, and I agree with it.
|
||||
|
||||
Counts: stamped 104 → **103**. Untouched 23 → **24**.
|
||||
|
||||
### 2. The `docs/` qualifier (vision's C1)
|
||||
|
||||
Stated as asked. **"127 live documentation files" is true for `docs/` only.** Definition: all
|
||||
`*.md` under `docs/`, minus `docs/archive/` and `docs/_old_structure/`. 318 total = 134 archive +
|
||||
57 `_old_structure` + 127 live.
|
||||
|
||||
Repo-wide the phrase undercounts: 21 live markdown files sit outside `docs/` and outside any named
|
||||
exclusion (17 under `guides/`, plus `README.md`, `AGENTS.md`, `CLAUDE.md`, `REPORT-A1207.md` at
|
||||
root). This PR does not stamp them and does not claim to.
|
||||
|
||||
### 3. `format` failure, and what it says about the header (`8a55c041`)
|
||||
|
||||
CI 2589 also failed `prettier --check` on **exactly one file**: the plan document I hand-wrote.
|
||||
Reproduced locally, rc=1, same single file. Fixed; the diff is 31 lines in, 31 out, all table
|
||||
column padding.
|
||||
|
||||
Worth stating for the review rather than burying: **the other 103 stamped documents pass
|
||||
`prettier --check` unchanged.** The `---\nkind:\nstatus:\n---` block is prettier-clean as applied.
|
||||
The formatting failure was in my prose, not in the contract header.
|
||||
|
||||
### 4. R1: the evidence inverts, the finding gets stronger
|
||||
|
||||
vision asks for a `kind` change on `docs/DEVELOPER-GUIDE/architecture/channel-protocol.md`, on the
|
||||
grounds that it "carries 7 normative MUSTs" while the contract says `guide` "decides nothing."
|
||||
|
||||
**The MUST count does not hold.** Uppercase RFC2119 terms (`MUST`, `MUST NOT`, `SHOULD`,
|
||||
`SHOULD NOT`, `SHALL`, `MAY`, `REQUIRED`) in that file: **0 lines**. Control: the identical grep
|
||||
returns 25 lines in `docs/requirements/native-kanban-sot.md`, so it finds them where they exist.
|
||||
The seven lowercase "must" occurrences all _disclaim_ authority rather than assert it: "must not be
|
||||
used as instructions", "must not be treated as current behavior", "must remain planned work", "must
|
||||
first specify", "before it can become architecture guidance." The file's own banner reads "it is
|
||||
not a runtime registry, an API contract, a requirements document."
|
||||
|
||||
**The citation half holds, and is larger than stated.** vision's line numbers are exact. I
|
||||
restated them earlier with wrong directories, which is worth naming because it is the same method
|
||||
failure fred and I already wrote up as C3 in the flatten plan: I matched on basename and assumed
|
||||
the path. The real ones, plus the two vision did not list:
|
||||
|
||||
| citing document | line | words used |
|
||||
| ------------------------------------------------------- | ---- | --------------------------------------------------- |
|
||||
| `docs/ADMIN-GUIDE/security/discord-ingress.md` | 141 | "**canonical** shared-contract and parity boundary" |
|
||||
| `docs/DEVELOPER-GUIDE/integrations/channel-adapters.md` | 28 | "The **canonical** architecture summary is" |
|
||||
| `docs/DEVELOPER-GUIDE/integrations/channel-adapters.md` | 183 | "**Canonical** channel protocol architecture" |
|
||||
| `docs/USER-GUIDE/workflows/discord-conversations.md` | 127 | "current shared types ... explicit parity boundary" |
|
||||
| `docs/SITEMAP.md` | 54 | index entry |
|
||||
| `docs/DEVELOPER-GUIDE/architecture/README.md` | 18 | index entry |
|
||||
|
||||
So the tension is real. Three live documents outside the two indexes cite it, across four
|
||||
citations, and three of those four use the word "canonical" for a document that spends its own
|
||||
banner denying it is canonical. **It is just not a MUST problem, and that
|
||||
changes what the fix is.** If the file is telling the truth about itself, the three "canonical"
|
||||
citations are wrong and the edit belongs in those three files, not in this one's `kind`.
|
||||
|
||||
**Left as `kind: guide` in this PR and flagged for the reviewer's call.** Restamping on evidence
|
||||
that inverts on reading would be worse than leaving it stamped and named.
|
||||
|
||||
### Unchanged
|
||||
|
||||
vision's C2 (no consumer), C4 (holding `parent` blocks nothing) and C5 (a front-mattered `.yaml`
|
||||
throws in `YAML.parse`) all reverified. C1's arithmetic closes at the stated ref.
|
||||
|
||||
## fred's six decisions, applied
|
||||
|
||||
Ruled on PR #1350 as comment 23693. Each is applied here; each is his call, not mine, and any of
|
||||
them is one line to reverse.
|
||||
|
||||
| # | decision | applied as |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| A | New contract wins; `docs/README.md` is rewritten in this PR and the 4 old-schema files convert in the same pass | `docs/README.md:149-190` rewritten; `type:` dropped from the 4 files, `title`/`audience`/`source_of_truth` kept |
|
||||
| B | `source-of-truth` leaves the `kind` enum and returns as an orthogonal boolean | enum is now 6 values; `docs/requirements/native-kanban-sot.md` stamped `kind: spec` + `source_of_truth: true` |
|
||||
| C | `status` gains a third value, `completed` | the two executed plans take it (evidence below) |
|
||||
| D | Kind follows content, never filename | `docs/native-kanban-sot/TASKS.md` stamped `kind: spec`, because its body says "a build plan, not a task tracker" |
|
||||
| E | The contract covers `.md` only, stated as a decision rather than left as a gap | written into `docs/README.md` with vision's `YAML.parse` measurement as the reason |
|
||||
| F | `channel-protocol.md` becomes `spec` | applied, with one correction and one consequence below |
|
||||
|
||||
### C: evidence the two plans are complete
|
||||
|
||||
Neither plan self-declares completion, so this is measured from the artifacts rather than taken
|
||||
from the documents:
|
||||
|
||||
- `2026-08-10-ci-queue-purpose-implementation.md` — the shipped guard carries the flag the plan
|
||||
specifies: `ci-queue-wait.sh --purpose push|merge`, exercised in this session at rc=0.
|
||||
- `2026-08-10-docs-structure-readme.md` — every section the plan specifies exists in
|
||||
`docs/README.md` today, including the Obsidian conventions and the source-of-truth precedence
|
||||
block. This PR is editing the artifact that plan produced.
|
||||
|
||||
### F: the MUST count does not hold, and the fix changes shape
|
||||
|
||||
Applied as ruled. But the ruling's stated grounds are half wrong, and the half that survives points
|
||||
somewhere else, so this is the one to look at again.
|
||||
|
||||
**Zero** uppercase RFC2119 terms in `channel-protocol.md`, not seven. Control: the identical grep
|
||||
returns 25 lines in `docs/requirements/native-kanban-sot.md`. The seven lowercase "must"
|
||||
occurrences all _disclaim_ authority: "must not be used as instructions", "must not be treated as
|
||||
current behavior", "must remain planned work".
|
||||
|
||||
**The citation half holds and is bigger than stated** (table in the section above).
|
||||
|
||||
**The consequence of applying F:** the file is now stamped `spec` while its own banner says "it is
|
||||
not a runtime registry, an API contract, a requirements document." Header and body now contradict
|
||||
each other, which is the defect this pass exists to remove. Either the banner is edited in this PR,
|
||||
or the three documents calling it canonical are the ones that are wrong. That is a content call and
|
||||
it is left to the reviewer rather than folded into a stamping pass.
|
||||
|
||||
## Q1 — the one question this pass cannot answer for itself
|
||||
|
||||
`docs/README.md` now **prescribes** the document contract, and it is the only live document under
|
||||
`docs/` with no `kind`. It is still on the operator-held list, so it is left unstamped.
|
||||
|
||||
By decision B it is arguably `kind: spec` with `source_of_truth: true` for the subject "document
|
||||
contract". The reason this is not applied unilaterally: it decides which document outranks the
|
||||
other when `docs/README.md` and `docs/plans/2026-08-20_stack-docs-flatten-and-alignment.md` disagree
|
||||
about the contract, and they already differ (the plan's enum has 7 values, the README's now has 6).
|
||||
That is an authority question, not a classification one.
|
||||
|
||||
## The old schema DID have a consumer, and CI found it
|
||||
|
||||
vision's C2 concluded "no consumer found" after searching by parsing primitive rather than by key
|
||||
name. fred's decision A rested on "no installed base to protect." I accepted both. **All three of
|
||||
us were wrong, and the full test suite is what proved it.**
|
||||
|
||||
`packages/mosaic/src/installation-documentation.spec.ts:39` asserted:
|
||||
|
||||
```ts
|
||||
expect(markdown).toMatch(/^---\n[\s\S]*?\nstatus: current\n[\s\S]*?\n---\n/);
|
||||
```
|
||||
|
||||
That is a raw regex over the markdown text, not a front-matter parse and not a key lookup, which is
|
||||
exactly why a search organised around parsing primitives could not see it. It pins
|
||||
`docs/USER-GUIDE/getting-started/quickstart.md` to the old vocabulary. Replacing `status: current`
|
||||
with `status: active` turned it red.
|
||||
|
||||
Updated to `status: active`, the contract's value for "in force", with the reason in a comment
|
||||
beside it. Verified by evaluating both regexes against the real file: old `false`, new `true`.
|
||||
Control: a page carrying `superseded-by` still fails the new regex, so the assertion still asserts
|
||||
something rather than matching anything with front matter.
|
||||
|
||||
**The method point, which outlives this file.** CI 2592 ran the whole suite against the stamped
|
||||
tree, 46 turbo tasks, and returned exactly one failing spec: this one. That is a stronger consumer
|
||||
search than any grep the three of us ran, because it does not depend on guessing how a consumer
|
||||
reads the file.
|
||||
|
||||
An earlier draft of this paragraph put a test count here, "1617 tests, 1 failed". **That number was
|
||||
wrong and it is withdrawn.** Extracting per-test totals from these pipeline logs is not reliable:
|
||||
the same regex over the same log format returns 1003 for 2592, 1022 for 2593 and 3471 for 2594,
|
||||
which are runs of the same suite. Three irreconcilable answers from one method is proof the method
|
||||
does not measure what it claims. What the log does carry reliably is the FAIL list and the turbo
|
||||
task line, so the claim is stated in those terms instead. The point never needed the count: one
|
||||
named failing spec is what refuted three hand-searches. **Run the suite before concluding a
|
||||
documentation change has no code consumers.** Two independent seats and a reviewer searching by
|
||||
hand missed the one that existed.
|
||||
|
||||
## fred's second pass: F withdrawn, Q1 answered
|
||||
|
||||
Both changes below are fred's rulings, applied. Neither is my judgement and I record whose it is.
|
||||
|
||||
### F is withdrawn: `channel-protocol.md` returns to `kind: guide`
|
||||
|
||||
Reverted. fred re-measured the file himself with a control and reached the count I reported: zero
|
||||
uppercase RFC2119 terms case-sensitive, seven lowercase `must`, every one disclaiming authority,
|
||||
under a banner that refuses requirements status. A page like that is a guide.
|
||||
|
||||
The reason this matters past one stamp is fred's own: F rested on "the doc graph outranks the
|
||||
page's own banner", which contradicts his decision D, "kind follows content, never the filename and
|
||||
never what other documents say about it". D is the rule. F was a counterexample to it, written in
|
||||
the same comment.
|
||||
|
||||
Neither rescue was taken. vision's adjective edit on the three citing docs and an edit to the
|
||||
page's banner would both have preserved a stamp that should not have been made. The three citing
|
||||
documents stay as they are: under vision's index-pointer reading, "canonical" claims the best page
|
||||
on a subject rather than normative force, so there is nothing to correct.
|
||||
|
||||
**What this costs the contract: nothing, and that is the point.** A kind that survives only by
|
||||
editing the evidence around it is not a classification.
|
||||
|
||||
### Q1 answered: `docs/README.md` is stamped `kind: spec`, `source_of_truth: true`
|
||||
|
||||
Applied. By D, a document that prescribes the contract has spec content. By B, `source_of_truth` is
|
||||
an orthogonal boolean and this is the authoritative statement of the contract, so it carries both.
|
||||
|
||||
The authority half of Q1 was whether stamping the README settles a conflict with the flatten plan,
|
||||
whose enum still has seven values against the README's six. fred's ruling: **a plan never outranks
|
||||
the artifact it planned.** The plan's enum is pre-decision-B staleness, not a competing authority.
|
||||
The plan is fred's file and he corrects it there.
|
||||
|
||||
The contract now applies to the document that states it. That was the only state in which it could
|
||||
be credible.
|
||||
|
||||
### Verification arithmetic, re-closed
|
||||
|
||||
128 live `.md` under `docs/` = **108 stamped** + 16 operator-held + 3 supersede deferrals + 1
|
||||
generated. The stamped count rose by one and the operator-held set fell by one, both because of the
|
||||
README; every other row is unchanged. Control unchanged: the verifier reports `valid=False` when a
|
||||
kind is corrupted to `nonsense`.
|
||||
|
||||
### One consumer finding that is not a defect
|
||||
|
||||
A sweep of every spec reading a path under `docs/` returns 10 files. Four read a live file:
|
||||
`fleet-north-star.spec.ts` and `installation-documentation.spec.ts` (both already caught by CI),
|
||||
`mutator-gate.acceptance.spec.ts` reading `compaction-revocation.md`, which passes under its `guide`
|
||||
stamp, and `roster-v2.spec.ts:366`, which reads `docs/fleet/reference/roster-v2.schema.json`.
|
||||
|
||||
The fourth is a real read of a real live file under `docs/` and is untouched only because decision E
|
||||
scopes the contract to `.md`. Had the contract covered every file under `docs/`, front matter in
|
||||
that JSON schema would have broken the spec, the same failure as the quickstart regex. E was
|
||||
load-bearing beyond the YAML-parse grounds it was decided on. No action; recorded so the `.md`
|
||||
boundary is not widened later without re-running this sweep.
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Documentation Plans
|
||||
|
||||
> **Status:** Current artifact index. Plans record approved intent and execution approach; they are not current product behavior or operational authority.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: tracking
|
||||
status: active
|
||||
---
|
||||
|
||||
# Tasks — Release Integrity Workstream (RI-050, #1275)
|
||||
|
||||
> Single-writer: the RI-050 orchestrator (jarvis, dragon-lin) only. Workers read but never modify.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Documentation Reports
|
||||
|
||||
> **Status:** Current evidence index. Reports record reviews, tests, audits, and deferred findings; they are not requirements or operational instructions by themselves.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Independent Code Review — #756 Official Discord Channel Plugin
|
||||
|
||||
**Verdict: APPROVE**
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Code Review Report — Gateway Security Hardening
|
||||
|
||||
## Scope Reviewed
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# #830 Documentation Completion Checklist
|
||||
|
||||
## Required artifacts
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# FCM-M5-001 Fleet Documentation Deferrals and Holds
|
||||
|
||||
**Issue:** #758 · **Branch:** `docs/758-fleet-config-operator-docs`
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mosaic Stack Documentation Catalog and Truth Audit
|
||||
|
||||
> **Status:** First-pass static audit — 2026-08-10
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Documentation Completion Checklist — #756 Official Discord plugin
|
||||
|
||||
## Required artifacts
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# FCM-M5-001 Fleet Documentation IA Closure Evidence
|
||||
|
||||
**Issue:** #758 · **Task:** FCM-M5-001
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mosaic Framework Consistency Audit
|
||||
|
||||
Date: 2026-02-17
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
VERDICT: GO
|
||||
|
||||
# Native Kanban/SOT canon independent re-review 2
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Independent Review — Native Kanban/SOT Canon
|
||||
|
||||
**Reviewer:** `enhance-sol` (independent of author `planner-sol`)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# KBN-101 contract independent security/architecture review
|
||||
|
||||
**Verdict: REQUEST CHANGES**
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# #751 Native Kanban/SOT canonical publication — Ultron final gate
|
||||
|
||||
**Verdict: GO** — zero BLOCKER/HIGH findings.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# QA Report — Gateway Security Hardening
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Performance Optimization — P8-003
|
||||
|
||||
**Branch:** `feat/p8-003-performance`
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# #1099 pipefail + early-exit sweep
|
||||
|
||||
Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0`
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Security Review — Issue #756
|
||||
|
||||
**Scope:** final current uncommitted Discord plugin, shared channel contract, gateway ingress, AgentService, and plugin registration delta
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
# Hierarchy Schema Contract (D2)
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
|
||||
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
|
||||
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
|
||||
document turns the ratified hierarchy into a concrete schema contract:
|
||||
tables, cardinalities, constraints, and ownership/transfer semantics. It is
|
||||
the prerequisite for the hierarchy command family and for the RBAC grant
|
||||
model (contract 2, `docs/requirements/rbac-grant-model.md`).
|
||||
|
||||
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
|
||||
explicit (§1.1); record class extended to include `hierarchy_grants`
|
||||
(§1.1); provenance corrections on legacy tables and the planning `projects`
|
||||
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
|
||||
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
|
||||
(§3.3); transfer transaction includes its audit write (§4.3); ownership
|
||||
invariant completed via contract 2 with the both-sides rule marked as new
|
||||
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
|
||||
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
|
||||
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
|
||||
MUST (allowlist, command surface, audit, corrected cardinality witness).
|
||||
|
||||
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
|
||||
reconciled with §5.2 — the transaction's writes are the single class-row
|
||||
mutation plus that mutation's §5.2 audit writes (event + outbox record),
|
||||
not "exactly two writes"; §6.3 extended with a closed writer-coverage
|
||||
witness so an unregistered internal writer cannot pass a registered-route
|
||||
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
|
||||
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
|
||||
cites §6.5; measured against `origin/contract/rbac-grants` head
|
||||
`501112d2`.)
|
||||
|
||||
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
|
||||
extended to raw SQL — it now also fails on class-table name literals
|
||||
inside SQL strings or tagged SQL templates outside the allowlist, so a
|
||||
raw-SQL writer that touches no schema symbol is still caught.
|
||||
|
||||
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
|
||||
raw-SQL execution primitive outside the allowlist fails the assertion
|
||||
regardless of its SQL content, closing the evasion where a
|
||||
dynamically constructed table name carries neither a schema symbol nor
|
||||
a class-table literal. The detection claim is now coextensive with
|
||||
what the three prongs statically see.
|
||||
|
||||
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
|
||||
corrected to three (F8); §6.3(b) gains the allowlist composition rules
|
||||
(no generic raw-SQL helper is allowlisted; an allowlisted module may
|
||||
not export caller-supplied-SQL execution) and fails outright on
|
||||
runtime code-construction primitives; the detection claim is scoped
|
||||
honestly to the stated syntactic forms, with evasions beyond static
|
||||
reach assigned to §5.1 review/audit rather than claimed for CI.
|
||||
|
||||
Revision 7 (terra r6 new F9): the false-positive remedy no longer
|
||||
contradicts the composition rules — legitimate non-hierarchy raw
|
||||
execution (e.g. the db package's migration runner) is dispositioned
|
||||
onto a second closed enumerated list, the infrastructure register,
|
||||
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
|
||||
from the writer allowlist, and importable only by registered modules
|
||||
or the operational entry points.
|
||||
|
||||
Revision 8 (terra r7 residual F9): the register's import rule made
|
||||
satisfiable by the live tree — imports are checked re-export-aware
|
||||
(package barrels followed), and each registered module carries its own
|
||||
closed importer enumeration, which may name operational entry points
|
||||
such as the Gateway's startup migration hook; named importers stay
|
||||
subject to prongs (i)/(ii) and gain no writer standing.
|
||||
|
||||
Revision 9 (terra r8 F10): revision 8 called the Gateway database
|
||||
module the runner's "one live importer today". That was false — the
|
||||
measured production importer set has four members. The enumeration
|
||||
example now lists the complete measured set, and the import analysis
|
||||
is extended to resolve literal dynamic `import()` routes, which two of
|
||||
the four members use.
|
||||
|
||||
Scope: the tenancy/authorization structure record class — companies,
|
||||
estates, platform-projects, workspaces, hierarchy grants, their parentage,
|
||||
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
|
||||
semantics (contract 2), roll-up projection semantics (contract 8), kanban
|
||||
planning entities inside workspaces (SOT §5), migration or retirement of
|
||||
legacy flat data (future work; see §1.3).
|
||||
|
||||
## 1. Record class and placement
|
||||
|
||||
1. The **tenancy/authorization structure record class** defined by
|
||||
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
|
||||
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
|
||||
in the class. Every rule addressed to "the class" in this contract
|
||||
(payload prohibition, mutation path, audit) binds all five tables. Class
|
||||
rows carry parentage, naming, grant, and audit-linkage data only — never
|
||||
task, plan, or any business/orchestration payload.
|
||||
References from business/orchestration rows into the class are limited
|
||||
to exactly one form: the canonical `workspace_id` tenancy column that
|
||||
REQ-TEN-001 requires on every canonical row, referencing
|
||||
`workspaces.id`. No business/orchestration row may reference a company,
|
||||
estate, platform-project, or grant id in any position, and no
|
||||
business/orchestration row may reference a workspace id in any
|
||||
non-tenancy position (dependency, claim target, work subject).
|
||||
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
|
||||
`workspace_id` obligation binds business/orchestration rows and does not
|
||||
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
|
||||
anchor the obligation points at.
|
||||
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
|
||||
in `packages/db/src/schema.ts`) are not part of this class. What A1
|
||||
§8.1.4 pins is narrower: the planning `projects` table and
|
||||
`platform_projects` stay distinct tables. This contract adds, as new
|
||||
policy ratified here: neither `teams` nor `projects` is repurposed as a
|
||||
hierarchy table. Their eventual migration or retirement is future work
|
||||
that no existing REQ assigns; it is out of scope here.
|
||||
|
||||
## 2. Tables and cardinalities
|
||||
|
||||
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
|
||||
The existing `projects` table is Brain planning data (so labeled in
|
||||
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
|
||||
MUST NOT merge the two. (A rename of either remains an implementation-PR
|
||||
decision under A1; this contract pins only that they stay distinct tables.)
|
||||
|
||||
1. `companies` — id (uuid pk), name, slug (unique per deployment),
|
||||
created_at, updated_at. N per deployment (D2).
|
||||
2. `estates` — id, name, slug, `company_id` NOT NULL →
|
||||
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
|
||||
company holds any number of estates.
|
||||
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
|
||||
`estates.id` ON DELETE RESTRICT. Exactly one estate per
|
||||
platform-project; an estate holds any number of platform-projects.
|
||||
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
|
||||
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
|
||||
per workspace. This table is the referent of every `workspace_id` column
|
||||
the SOT requires on canonical rows.
|
||||
5. **Chain resolution is by construction.** Because every parent FK is NOT
|
||||
NULL and single-valued (one FK column, no parentage edge tables, no
|
||||
multi-parent forms, no nullable "detached" states), each workspace
|
||||
resolves to exactly one platform-project → estate → company chain (A1
|
||||
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
|
||||
many children per parent is valid data.
|
||||
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
|
||||
`estates.slug` is unique within its company, `platform_projects.slug`
|
||||
within its estate, `workspaces.slug` within its platform-project
|
||||
(composite unique constraints). Display names are unconstrained beyond
|
||||
NOT NULL.
|
||||
7. No hierarchy table carries a `metadata` jsonb column or any
|
||||
free-form payload field. The columns declared in this section and §3
|
||||
are exhaustive: a class table's column set is exactly its declared set
|
||||
(verified per §6.2) — nothing else (A1 §8.1.2).
|
||||
|
||||
## 3. Grant attachment points
|
||||
|
||||
The grant vocabulary (which roles exist, what each permits, how evaluation
|
||||
and revocation work) is contract 2. This contract pins only the schema
|
||||
shape contract 2 attaches to:
|
||||
|
||||
1. `hierarchy_grants` — id, subject (exactly one of `user_id` → `users.id`,
|
||||
`team_id` → `teams.id`; CHECK-enforced exactly-one-of), target (exactly
|
||||
one of `company_id`, `estate_id`, `platform_project_id`;
|
||||
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
|
||||
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
|
||||
`users.id`, created_at.
|
||||
2. Uniqueness: at most one grant row per (subject, target, role). Because
|
||||
the subject and target columns are nullable by design, ordinary
|
||||
PostgreSQL composite uniqueness treats NULLs as distinct and would not
|
||||
enforce this. The implementation MUST use a single
|
||||
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
|
||||
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
|
||||
equivalent partial unique indexes (one per subject×target form). The
|
||||
pinned Drizzle ORM supports `nullsNotDistinct()`.
|
||||
3. Delete actions are split by column class:
|
||||
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
|
||||
ON DELETE CASCADE — the one permitted cascade in this class. A grant
|
||||
on a deleted node is meaningless and fail-open if retained. Cascaded
|
||||
grant deletions are audited per §5.2.
|
||||
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
|
||||
RESTRICT. The identity contract (§7.3) gates user deletion today and
|
||||
defines no team-deletion rule; this contract does not invent one.
|
||||
These FKs stay RESTRICT until an explicit deletion-and-retention
|
||||
contract ratifies otherwise.
|
||||
4. Workspace-level access is evaluated, not stored here: a grant at any of
|
||||
the three levels evaluates down the chain to workspace-scoped
|
||||
authorization (A1 §8.1.3). No `workspace_id` column exists on
|
||||
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
|
||||
mechanism inside the SOT schema, and the chain adds where grants can be
|
||||
declared, never a bypass.
|
||||
|
||||
## 4. Ownership and transfer
|
||||
|
||||
"Assets are transferable subject to the structure" (PRD Part I §4):
|
||||
|
||||
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
|
||||
workspace → new platform-project, platform-project → new estate, estate
|
||||
→ new company. Nothing else in the class or the SOT changes: business
|
||||
and orchestration rows inside affected workspaces are untouched, keep
|
||||
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
|
||||
"chain maintenance").
|
||||
2. Transfer authorization requires authority over BOTH the source and the
|
||||
destination parent. This both-sides predicate is **new policy
|
||||
introduced by this contract pair** (D2/A1 do not state it); its
|
||||
evaluation semantics are contract 2 §5. The structural half — that the
|
||||
transfer command evaluates it before mutating — binds here.
|
||||
3. A transfer transaction mutates exactly one class-table row — the
|
||||
single-row parent-FK update — and contains, beyond that, only the
|
||||
§5.2 audit writes for that mutation (the audit event and its
|
||||
hierarchy-outbox record, committing in the same transaction). No other
|
||||
class, business, or orchestration row changes. There are no multi-row
|
||||
transfer batches at the schema level; bulk moves are N audited
|
||||
transfers.
|
||||
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
|
||||
grant structure: a "company owner" is a subject with an `owner` grant
|
||||
on that company or an ancestor (contract 2 §2), not a column. The
|
||||
ownership invariant across the contract pair: a node may hold zero
|
||||
direct owner grants (authority can derive from an ancestor grant); node
|
||||
creation names the initial `owner` grant in the same audited operation
|
||||
and the wizard seeds the first company's owner the same way (contract 2
|
||||
§4.3); transfer and revocation semantics are contract 2 §§5–6. This
|
||||
avoids column-encoded authority of the kind the legacy schema carries
|
||||
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
|
||||
`team_members.role` is a further authority field — none of them
|
||||
evaluable under a grant model).
|
||||
|
||||
## 5. Mutation path, audit, and deletion
|
||||
|
||||
1. All hierarchy mutations flow through the same sole-writable-SOT,
|
||||
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
|
||||
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
|
||||
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
|
||||
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
|
||||
substitute. Concretely:
|
||||
- Every create, rename, transfer, grant create/change/revoke, and
|
||||
delete — including every grant deletion cascaded by a node delete —
|
||||
emits a semantic audit event carrying actor, verb, target, and (for
|
||||
transfers) source and destination parents, with the correlation,
|
||||
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
|
||||
defines.
|
||||
- The state change and its audit event(s) commit in the same
|
||||
transaction, delivered through a transactional outbox. Hierarchy
|
||||
events are not workspace-scoped rows and do not ride the workspace
|
||||
outbox; they get an equivalent hierarchy outbox under the same
|
||||
append-only, same-transaction rules.
|
||||
- **Deletion-safe linkage:** audit events reference their target by an
|
||||
immutable snapshot (id, slug, and parent chain at event time), never
|
||||
by a foreign key into the class tables, so append-only events survive
|
||||
the deletion of their target.
|
||||
3. Deletion is fail-closed bottom-up: a hierarchy record with children
|
||||
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
|
||||
operation subject to the kanban SOT's own rules and is not granted any
|
||||
new semantics by this contract.
|
||||
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
|
||||
roll-up read mutates nothing — not hierarchy state, and not business or
|
||||
orchestration state: it must not mutate, claim, order, or gate
|
||||
workspace work. Contract 8 owns projection details but cannot narrow
|
||||
this rule. This contract additionally guarantees the chain roll-ups
|
||||
aggregate over is unique and non-null (§2.5).
|
||||
|
||||
## 6. Verification requirements
|
||||
|
||||
Binding on the implementing PRs (extends A1 §8.3):
|
||||
|
||||
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
|
||||
with a null parent FK refused; insert with one valid parent accepted;
|
||||
two siblings under one parent accepted (the control proving the
|
||||
constraint rejects only what §2.5 forbids); catalog assertion that each
|
||||
child table has exactly one parent-FK column and no parentage edge
|
||||
table exists. Composite slug uniqueness per parent (duplicate slug
|
||||
under same parent refused; same slug under different parents accepted).
|
||||
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
|
||||
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
|
||||
(subject, target, role) row refused for each of the six subject×target
|
||||
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
|
||||
`granted_by`, and all `name`/`slug` columns witnessed.
|
||||
2. Column allowlist: an information_schema assertion that each class
|
||||
table's column set is exactly the set declared in §2/§3 — the bounded
|
||||
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
|
||||
3. Command surface: two witnesses, both required (§5.1). (a) Route
|
||||
inventory: an assertion over the Gateway's registered hierarchy
|
||||
routes/commands proving the registered mutation surface is exactly the
|
||||
declared hierarchy command family — no generic CRUD endpoint. (b)
|
||||
Writer coverage — the closed allowlist a route inventory cannot
|
||||
provide: a static CI assertion over the Gateway and package sources
|
||||
with three prongs, each bound to one explicitly enumerated allowlist
|
||||
of hierarchy command/repository modules. (i) Symbol prong: write
|
||||
references to the class-table schema symbols (insert, update, delete)
|
||||
occur only in allowlisted modules. (ii) Literal prong: a class-table
|
||||
name appearing inside a SQL string or tagged SQL template outside the
|
||||
allowlist fails the assertion — this is what catches a raw-SQL writer
|
||||
that references no schema symbol. (iii) Raw-execution prong: any call
|
||||
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
|
||||
driver-level query/execute) outside the allowlist fails the
|
||||
assertion, regardless of what the SQL string contains or how it is
|
||||
constructed — the call site is statically detectable even when a
|
||||
dynamically assembled table name is not, so a raw writer with a
|
||||
runtime-built identifier is caught by its primitive, not its
|
||||
payload. Two composition rules keep prong (iii) meaningful: the
|
||||
allowlist names hierarchy command/repository modules only — a
|
||||
generic raw-SQL helper or database-utility module is never
|
||||
allowlisted; and an allowlisted module MUST NOT export a function
|
||||
that executes caller-supplied SQL (such an export is itself a
|
||||
raw-execution primitive, and the exporting module is treated as
|
||||
unallowlisted for prong (iii) if it does). Legitimate raw execution
|
||||
that is not a hierarchy writer — e.g. the migration runner in the
|
||||
db package — lives on a second, separately enumerated
|
||||
**infrastructure register**, distinct from the writer allowlist and
|
||||
equally closed. A registered module is exempt from prong (iii) only:
|
||||
prongs (i) and (ii) apply to it with no exemption, so it can hold no
|
||||
class-table schema symbol or class-table SQL literal, and it can
|
||||
never appear on the writer allowlist. To close the laundering path,
|
||||
the same assertion checks imports, and the import analysis is
|
||||
**re-export-aware**: it follows package barrels and re-exports, so a
|
||||
route hidden behind an index module is still a route — and it
|
||||
resolves literal dynamic imports the same way: an
|
||||
`await import('<literal specifier>')` is an import edge like any
|
||||
static import, not an evasion of the analysis (a dynamic import of
|
||||
the db package whose specifier is not a literal fails the assertion
|
||||
outright, because it makes the import graph unanalyzable). A
|
||||
registered module may be imported only by other registered modules
|
||||
or by importers named on that module's own closed importer
|
||||
enumeration in the register — operational entry points such as the
|
||||
migration/bootstrap CLI or the Gateway's startup migration hook.
|
||||
The enumeration names the complete permitted production consumer
|
||||
set, and completeness is measured, not asserted: the migration
|
||||
runner's measured production importer set today has four members —
|
||||
the Gateway database module (reached through the db package
|
||||
barrel), the storage package's Postgres adapter, and two mosaic CLI
|
||||
commands, the fleet-backlog command and the gateway verify command,
|
||||
both routed through literal dynamic imports of the db package — so
|
||||
its enumeration names those four. A module that only receives the
|
||||
runner's functions by parameter injection (the gateway schema-check
|
||||
module takes them as arguments from the verify command) has no
|
||||
import edge of its own and is not enumerated. Any import route
|
||||
outside the enumeration fails the assertion. Being a
|
||||
named importer confers nothing else: the importer stays fully
|
||||
subject to prongs (i) and (ii), gains no writer-allowlist standing,
|
||||
and whether it uses the registered module beyond its operational
|
||||
purpose is a §5.1 review question, not a static claim. Runtime code-construction
|
||||
primitives (`eval`, `new Function`) anywhere in the scanned sources
|
||||
fail the assertion outright, allowlist or not. Schema definitions
|
||||
and generated migrations are excluded from the literal prong; a
|
||||
false positive is resolved in the same PR by adding the module to
|
||||
the one enumerated list its role permits — the writer allowlist for
|
||||
a hierarchy command/repository module, the infrastructure register
|
||||
for non-hierarchy raw execution — never by weakening the assertion,
|
||||
and neither list may take a module the composition rules bar from
|
||||
it. Both lists are closed, and the assertion's detection
|
||||
claim is exactly its prongs: it statically surfaces every writer
|
||||
expressed as a schema-symbol reference, a class-table SQL literal, a
|
||||
raw-execution call site, or runtime code construction. An evasion
|
||||
engineered outside those syntactic forms is a §5.1 violation that
|
||||
review and audit own — the witness does not claim to catch what
|
||||
static analysis cannot see, and any such evasion found later is
|
||||
corrected as a conformance defect, not grandfathered.
|
||||
4. Audit witnesses: for each mutation class (create, rename, transfer,
|
||||
grant create/change/revoke, delete) — the event exists after commit
|
||||
with actor/verb/target and same-transaction atomicity; a rolled-back
|
||||
mutation leaves no event (rollback witness); a node delete's cascaded
|
||||
grant deletions are each covered by events; events survive deletion of
|
||||
their target (query the events of a deleted node).
|
||||
5. Transfer tests: parent-FK update moves the subtree resolution and
|
||||
modifies zero business/orchestration rows (row-count and content
|
||||
assertions on workspace contents before/after); transfer without
|
||||
authority on the source or on the destination side is refused (with
|
||||
contract 2 §7.8).
|
||||
6. Deletion tests: delete with children refused at the database level;
|
||||
delete of a leaf cascades its grants and nothing else; deleting a user
|
||||
or team that is a grant subject (or `granted_by` referent) is refused
|
||||
(RESTRICT witnesses for §3.3).
|
||||
7. Negative tests: no business/orchestration table accepts a company,
|
||||
estate, platform-project, or grant id in any reference position, and
|
||||
none accepts a workspace id in any non-tenancy position; the canonical
|
||||
tenancy FK control — a business row inserted with a valid
|
||||
`workspace_id` succeeds, with an invalid one is refused; roll-up
|
||||
endpoints mutate no canonical state anywhere (assert zero writes across
|
||||
hierarchy AND workspace tables, not hierarchy only); readers see
|
||||
aggregates only over workspaces they are authorized on, with no
|
||||
cross-tenant existence oracles (A1 §8.3 acceptance 3).
|
||||
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
|
||||
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
|
||||
in the `test` CI step; mocked specs cannot witness database constraints.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–6 as written, with one decision embedded: hierarchy
|
||||
records carry no owner column — ownership is expressed solely through
|
||||
grants (§4.4) — say "agreed" or name the ownership model you want.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user