Skip to content

feat(catalog): feature 010 Phase A — workspace placement and dependency boundary - #84

Merged
mbeacom merged 3 commits into
mainfrom
mbeacom-feat-010-phase-a-workspace-boundary
Aug 5, 2026
Merged

feat(catalog): feature 010 Phase A — workspace placement and dependency boundary#84
mbeacom merged 3 commits into
mainfrom
mbeacom-feat-010-phase-a-workspace-boundary

Conversation

@mbeacom

@mbeacom mbeacom commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Implements T001–T012 of specs/010-catalog-backstage/tasks.md — the whole of Phase A. All twelve boxes are checked; the tasks.md diff is twelve checkboxes and nothing else.

Standing honesty statement

ADR-0014 rung 1 only. Nothing here is reference-verified (rung 2) or externally validated (rung 3), and no file claims either. Any verification below is maintainer-owned, which is not external, third-party, or community adoption.

No release is scheduled, implied, or prepared. ADR-0020 clause 9 defers both the release vehicle and the decision to release at all. Both packages sit at version 0.0.0, are absent from RELEASE_PACKAGES, ship no dist, and declare no exports map.

This feature has produced no evidence beyond the negative cases in this PR. No generator exists, no generator has run, and no envelope exists. Every behavioural statement in the specs remains a requirement, not a report.

Barrier B has not cleared. That is Phase B plus the independent T019 audit, running separately. Nothing here is behind the barrier: Phase A reads no descriptor, computes no ownership result, and produces no envelope.

What lands

Two packages, placed per contracts/package-boundary.md §1:

Path Kind
@adrkit/catalog-backstage packages/adapters/catalog-backstage/ adapter, independently versioned (ADR-0007)
@adrkit/catalog-envelope packages/catalog-envelope/ non-adapter workspace library

Both are picked up by the existing root workspaces globs with no change to them, which §1 requires — a needed change there would signal wrong placement. The root package.json diff is empty.

Guards, each observed rejecting before being relied on (ADR-0016):

  • test/no-dynamic-loader.test.ts (FR-002) — seven runtime-resolution and registry rules, plus a positive static-import assertion on a specific value rather than on the absence of a loader.
  • test/envelope-shape-locality.test.ts (FR-005, FR-044) — no reference to the published schema surface, no import edge between the two packages, no relative import escaping either package root, no import of an undeclared module.
  • scripts/check-deps.ts — explicit allowedDependenciesFor() entries for both packages (§2). The @adrkit/cli entry is untouched, per §7.
  • scripts/check-deps.test.ts — nine retained negative cases.

Both scans report the files they read and refuse to conclude anything from an empty file list; the excluded-from-scan set is pinned so it cannot grow quietly.

All nine deposited patches were round-tripped — applied, re-run, confirmed to reproduce their documented exit codes, restored. The "Reproducing" sections in each deposit are therefore verified rather than asserted.


Five things a reviewer most needs to see

1. check:deps structurally cannot see schema/ — and T009 is checked anyway, on SC-015's wording

FR-003 covers three surfaces: packages/core, packages/cli, and schema/. The first two fail bun run check:deps at exit 1. The third does not.

schema/ has no package.json. readWorkspacePackages() walks packages/ and reads manifests, so a directory with no manifest is never visited. With a real import … from '@adrkit/catalog-backstage' added to schema/adr.schema.ts:

$ bun run check:deps
core-has-no-adapter-deps: ok
EXIT=0

That edge is rejected by bun run typecheck instead:

schema/adr.schema.ts(2,30): error TS2307: Cannot find module '@adrkit/catalog-backstage' or its corresponding type declarations.
EXIT=2

because bunfig.toml's linker = "isolated" leaves root-level files no @adrkit/ scope to resolve through — ls node_modules/@adrkit/ returns No such file or directory. That is Constitution Principle III's own stated reason the setting is load-bearing, observed doing the work it is there to do.

T009 is checked on SC-015's wording, with the deviation named. SC-015 reads "from packages/core, packages/cli, or the schema/ surface … causes the isolation check to fail" — the isolation check, not check:deps specifically. All three surfaces are observed failing, retained, and observed passing on removal. The deviation is that one of the three is discharged by typecheck rather than check:deps, recorded prominently in the deposit rather than smoothed over. T009's own instruction to "run bun run check:deps; observe the failure" is not satisfiable for schema/, and no change to check-deps.ts would make it so without teaching the script to scan sources rather than manifests.

A test pins this as a specific observed value ("does NOT see a manifest-less directory such as schema/, and this records that limitation"), so nobody later assumes coverage that does not exist.

2. §4's trap has a level above it

contracts/package-boundary.md §4 records that a package with no allowlist entry is silently unconstrainedallowedDependenciesFor() returns undefined and the allowed-surface guard is skipped, so it passes check:deps no matter what it declares.

The schema/ case is the same shape one level up: a directory with no manifest is invisible outright. In both, absence of a rule and a satisfied rule render as the same green string.

A reader who assumes check:deps covers schema/ would be wrong — and wrong in the direction of a check that cannot fail, which is the only direction that matters in a governance tool.

3. T012 case C is byte-identical to a clean run

With the adapter's allowlist entry removed and undici — a network client — still declared:

$ bun run check:deps
core-has-no-adapter-deps: ok
EXIT=0

case-c-entry-removed-trap.observed.txt and restored.observed.txt are byte-identical, verified with diff rather than asserted. There is no signal to read.

This is what makes cases A and B mean anything: without it, an exit-1 could not distinguish "the entry exists and rejected undici" from "some other guard happened to reject it." Case C removes only the entry, holds everything else fixed, and the violation disappears.

The honest consequence, stated in the deposit: restored.observed.txt alone evidences nothing. It is identical to the trap's output. It is meaningful only alongside A and B, which show the same command does distinguish an allowed surface from a disallowed one while the entries are present.

4. T010 fires two guards, and the extra one confirms placement

T010 names one guard. Two fire, and both are recorded:

packages/catalog-envelope/package.json: @adrkit/catalog-envelope dependencies.@adrkit/catalog-backstage - non-adapter workspace depends on an adapter package
packages/catalog-envelope/package.json: @adrkit/catalog-envelope dependencies.@adrkit/catalog-backstage - @adrkit/catalog-envelope declares a dependency outside its allowed public surface

The first is more than a duplicate finding. isAdapterPackage() classifies purely by path prefix, so non-adapter workspace depends on an adapter package can only be emitted for a package located outside packages/adapters/. Its appearance is therefore positive mechanical confirmation that the consumer is placed correctly — the FR-044 placement half, observed rather than asserted.

Had the consumer been created under packages/adapters/, this line would simply be absent, and the misplacement silent. That is the same failure shape as everything else in this PR, which is why it is recorded rather than treated as noise.

The mirror case is worth reading beside it: T011 fires exactly one guard, correctly, since the non-adapter guard's condition is !adapterPackage and the adapter is one. So in the adapter→consumer direction the allowlist entry is the only thing holding the boundary — which is precisely why case C matters.

5. Stale line-number citations — a follow-up for the maintainer, not fixed here

T008's two allowlist entries shifted everything below them by +41:

Reference Cited Actual
non-adapter workspace depends on an adapter package 175–182 reason emitted at 222, guard block 216–224
<name> declares a dependency outside its allowed public surface 196–204 reason emitted at 242, guard block 236–244
allowedDependenciesFor() returns undefined 151 192

Emitted reasons unchanged; only the numbers moved. contracts/package-boundary.md §4 is normative and currently points at non-guard lines.

Deliberately not fixed in this PR. Task text and the contract are the maintainer's to reconcile centrally, alongside the other findings, once the concurrent Phase B session lands. The intended fix is to replace the line-number citations with the emitted reason strings: the strings are stable across edits and are already asserted verbatim by scripts/check-deps.test.ts, so a string citation is checked by the suite — whereas a line number is checked by nobody, and is itself a reference that cannot fail.


Evidence deposits

specs/010-catalog-backstage/evidence/negative-cases/ — created here, because no task creates it. Four subdirectories, one per case, each self-describing: a .patch carrying the failing input, verbatim .observed.txt captures written by the run rather than transcribed from it, the restored-pass observation, and a README naming the command, exact strings, and exit code.

Every capture opens with the command that produced it, because check:deps and typecheck are not interchangeable here — case 1 above passes under one and fails under the other. Two captures that initially lacked that header were fixed by re-running the observation, not by editing the file: a capture that gets hand-edited stops being a capture.

negative-cases/README.md documents the per-subdirectory convention and is deliberately not an index: the tree is shared across phases, and an enumeration would be stale on the next deposit — precisely the failure ADR-0016 exists to prevent.

evidence/README.md, evidence/frozen-expectations/, and evidence/accept-corpus-freeze/ are untouched and absent; they belong to the concurrent Phase B session.

Verification

Gate Result
bun test 904 pass / 0 fail (baseline 857)
bun run typecheck clean
bun run check:deps core-has-no-adapter-deps: ok
bun run adr lint 20 records, 0 errors, 0 warnings
bun run build / bun run lint both new packages green
bun install --frozen-lockfile green

Incidental: bun.lock's @adrkit/spec-kit version moves 0.1.00.1.2, correcting pre-existing staleness — that manifest already read 0.1.2 on main and is unmodified here.

Refs: specs/010-catalog-backstage/tasks.md T001–T012 · ADR-0007, ADR-0010, ADR-0013, ADR-0014, ADR-0016, ADR-0020 · Constitution v1.0.2 Principle III

mbeacom and others added 3 commits August 4, 2026 23:45
…cy boundary

Implements T001–T008 of `specs/010-catalog-backstage/tasks.md`, and carries out
the T009–T012 observed-failing runs whose enforcement half lands here.

Two new packages, placed per `contracts/package-boundary.md` §1:

- `packages/adapters/catalog-backstage/` (`@adrkit/catalog-backstage`),
  independently versioned per ADR-0007 with the `"//versioning"` note §6
  requires, since a reader seeing its `@adrkit/core` dependency would otherwise
  infer coupled versioning and be wrong.
- `packages/catalog-envelope/` (`@adrkit/catalog-envelope`), deliberately not
  under `packages/adapters/`, so `isAdapterPackage()` classifies it as a
  non-adapter by location rather than by exception (§3.1).

Both are picked up by the existing root `workspaces` globs with no change to
them, which §1 requires: a needed change there would signal wrong placement.

Guards, each observed rejecting before being relied on (ADR-0016):

- `test/no-dynamic-loader.test.ts` (FR-002) — seven runtime-resolution and
  registry rules over the adapter source, plus a positive static-import
  assertion on a specific value rather than on the absence of a loader.
- `test/envelope-shape-locality.test.ts` (FR-005, FR-044) — no reference to the
  published schema surface, no import edge between the two packages, no relative
  import escaping either package root, and no import of an undeclared module.
  The "shape is declared locally" half is enforced by those invariants rather
  than by counting declarations, which would pass vacuously today.
- `scripts/check-deps.ts` — explicit `allowedDependenciesFor()` entries for both
  packages (§2). The `@adrkit/cli` entry is untouched, per §7.
- `scripts/check-deps.test.ts` — the retained negative cases for the core→adapter,
  cli→adapter, consumer→adapter, adapter→consumer, and disallowed-dependency
  edges, plus the §4 trap itself: a package with no allowlist entry passes with
  the same disallowed dependency, which is what makes the others mean anything.

Both scans report the files they read and refuse to conclude anything from an
empty file list, and the excluded-from-scan set is pinned so it cannot grow
quietly.

Two findings recorded in the tests rather than left as assumptions:

- `check:deps` cannot see `schema/` — it has no manifest, so FR-003's `schema/`
  clause is held by `bunfig.toml`'s isolated linker and `typecheck` (TS2307),
  not by this check. T009's third case is therefore not satisfiable as written.
- The lockfile's `@adrkit/spec-kit` version moves 0.1.0 → 0.1.2, correcting
  pre-existing staleness; that manifest already read 0.1.2 at HEAD and is
  unmodified here.

T009–T012 are left unchecked: their `Files` lines also name
`specs/010-catalog-backstage/evidence/negative-cases/`, a tree owned by the
concurrent Phase B session and not written here.

ADR-0014 rung 1 only. No rung-2 or rung-3 claim; no release scheduled or
prepared (ADR-0020 clause 9).

Refs: specs/010-catalog-backstage/tasks.md T001–T012

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Completes T009–T012 by depositing the retained failing inputs ADR-0016 clause 2
asks for, into the four subdirectories those tasks name. Creates
`specs/010-catalog-backstage/evidence/negative-cases/`, which no task creates.

Each case directory is self-describing: a `.patch` carrying the failing input,
verbatim `.observed.txt` captures written by the run rather than transcribed from
it, the restored-pass observation, and a README naming the command, the exact
emitted strings, and the exit code. All nine patches were round-tripped —
applied, re-run, and confirmed to reproduce their documented exit codes.

Observed against 1014972, Bun 1.3.14, TypeScript 6.0.3.

- `dep-core-to-adapter/` (T009, SC-015) — core→adapter and cli→adapter both fail
  `check:deps` at exit 1. The third surface, `schema/`, does **not**: it carries
  no `package.json`, so `readWorkspacePackages()` never visits it and
  `check:deps` exits 0 printing `core-has-no-adapter-deps: ok`. That edge is
  rejected by `bun run typecheck` instead, with `TS2307: Cannot find module
  '@adrkit/catalog-backstage'`, because the isolated linker leaves root-level
  files no `node_modules/@adrkit/` to resolve through — Constitution Principle
  III's stated reason the setting is load-bearing, observed working. Recorded
  under the command that genuinely produces it, with the distinction stated
  rather than smoothed over.
- `dep-consumer-to-adapter/` (T010, FR-044 i) — two guards fire, not the one the
  task names; both recorded. The non-adapter violation is also positive
  mechanical confirmation that the consumer is placed outside
  `packages/adapters/`, since that reason can only be emitted for a package that
  is.
- `dep-adapter-to-consumer/` (T011, FR-044 ii) — exactly one guard fires, since
  the adapter *is* an adapter and the non-adapter guard's condition excludes it.
  In this direction the allowlist entry is the only thing holding the boundary.
- `dep-allowlist-present/` (T012) — `undici` rejected in each package
  independently, plus the §4 trap: with the entry removed and `undici` still
  declared, `check:deps` exits 0, and its capture is **byte-identical** to the
  clean-tree capture (verified with `diff`). That identity is what makes the
  first two cases mean anything.

Also recorded: T008 shifted the two guards from lines 175–182 and 196–204 to
216–224 and 236–244, so the line citations in tasks.md and
`contracts/package-boundary.md` §4 are stale. The emitted reasons are unchanged.

`negative-cases/README.md` documents the per-subdirectory convention and is
deliberately not an index, since the tree is shared across phases and an
enumeration would go stale.

Untouched, as they belong to the concurrent Phase B session: `<EVIDENCE>/README.md`,
`<EVIDENCE>/frozen-expectations/`, `<EVIDENCE>/accept-corpus-freeze/`.

Verification: `bun test` 904 pass / 0 fail, `bun run typecheck` clean,
`bun run check:deps` ok, `bun run adr lint` 20 records / 0 errors / 0 warnings.

ADR-0014 rung 1 only. No rung-2 or rung-3 claim; no release scheduled or
prepared (ADR-0020 clause 9).

Refs: specs/010-catalog-backstage/tasks.md T009–T012

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both captures showed only bun's own `$ bun run scripts/check-deps.ts` wrapper
echo, while every other capture in the tree opens with the invoked command
`$ bun run check:deps`. The negative-cases convention requires each capture to
name the command that produced it, because `check:deps` and `typecheck` are not
interchangeable here — case 3 in this same directory passes under one and fails
under the other.

Re-captured by re-running each observation rather than editing the files, so
they remain verbatim output rather than transcription. Both still exit 1 with
the same two reason strings; only the header line is new.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings August 5, 2026 03:58
@mbeacom
mbeacom merged commit 99ba8d2 into main Aug 5, 2026
10 checks passed
@mbeacom
mbeacom deleted the mbeacom-feat-010-phase-a-workspace-boundary branch August 5, 2026 04:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces Phase A scaffolding and dependency boundaries for the Backstage catalog adapter and envelope consumer.

Changes:

  • Adds two unreleased workspace package skeletons.
  • Adds dependency and source-boundary guards with tests.
  • Records negative-case evidence and completes T001–T012 checkboxes.

Reviewed changes

Copilot reviewed 41 out of 42 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
specs/010-catalog-backstage/tasks.md Marks Phase A tasks complete.
specs/010-catalog-backstage/evidence/negative-cases/README.md Defines evidence conventions.
specs/010-catalog-backstage/evidence/negative-cases/dep-core-to-adapter/restored.observed.txt Records restored checks.
specs/010-catalog-backstage/evidence/negative-cases/dep-core-to-adapter/README.md Documents core, CLI, and schema cases.
specs/010-catalog-backstage/evidence/negative-cases/dep-core-to-adapter/case-3-schema-to-adapter.patch Captures schema violation input.
specs/010-catalog-backstage/evidence/negative-cases/dep-core-to-adapter/case-3-schema-to-adapter.observed.txt Records schema-case output.
specs/010-catalog-backstage/evidence/negative-cases/dep-core-to-adapter/case-2-cli-to-adapter.patch Captures CLI violation input.
specs/010-catalog-backstage/evidence/negative-cases/dep-core-to-adapter/case-2-cli-to-adapter.observed.txt Records CLI-case output.
specs/010-catalog-backstage/evidence/negative-cases/dep-core-to-adapter/case-1-core-to-adapter.patch Captures core violation input.
specs/010-catalog-backstage/evidence/negative-cases/dep-core-to-adapter/case-1-core-to-adapter.observed.txt Records core-case output.
specs/010-catalog-backstage/evidence/negative-cases/dep-consumer-to-adapter/restored.observed.txt Records restored consumer check.
specs/010-catalog-backstage/evidence/negative-cases/dep-consumer-to-adapter/README.md Documents consumer-to-adapter case.
specs/010-catalog-backstage/evidence/negative-cases/dep-consumer-to-adapter/observed.txt Records rejected dependency.
specs/010-catalog-backstage/evidence/negative-cases/dep-consumer-to-adapter/dep-consumer-to-adapter.patch Captures dependency violation.
specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/restored.observed.txt Records restored allowlist check.
specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/README.md Documents allowlist trap cases.
specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-c-entry-removed-trap.patch Removes adapter allowlist entry.
specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-c-entry-removed-trap.observed.txt Records silent allowlist trap.
specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-b-consumer-disallowed-dep.patch Adds forbidden consumer dependency.
specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-b-consumer-disallowed-dep.observed.txt Records consumer rejection.
specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-a-adapter-disallowed-dep.patch Adds forbidden adapter dependency.
specs/010-catalog-backstage/evidence/negative-cases/dep-allowlist-present/case-a-adapter-disallowed-dep.observed.txt Records adapter rejection.
specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/restored.observed.txt Records restored adapter check.
specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/README.md Documents adapter-to-consumer case.
specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/observed.txt Records rejected dependency.
specs/010-catalog-backstage/evidence/negative-cases/dep-adapter-to-consumer/dep-adapter-to-consumer.patch Captures dependency violation.
scripts/check-deps.ts Adds package dependency allowlists.
scripts/check-deps.test.ts Tests catalog dependency boundaries.
packages/catalog-envelope/tsconfig.json Configures envelope TypeScript sources.
packages/catalog-envelope/src/index.ts Adds the consumer entry point.
packages/catalog-envelope/README.md Documents status and boundaries.
packages/catalog-envelope/package.json Defines the consumer workspace package.
packages/adapters/catalog-backstage/tsconfig.json Configures adapter TypeScript sources.
packages/adapters/catalog-backstage/test/source-scan.ts Adds source-scanning helpers.
packages/adapters/catalog-backstage/test/no-dynamic-loader.test.ts Guards against dynamic loaders.
packages/adapters/catalog-backstage/test/envelope-shape-locality.test.ts Guards schema and package locality.
packages/adapters/catalog-backstage/src/index.ts Adds the adapter entry point.
packages/adapters/catalog-backstage/README.md Documents adapter status and constraints.
packages/adapters/catalog-backstage/package.json Defines the adapter workspace package.
packages/adapters/catalog-backstage/NOTICE Adds package notices.
packages/adapters/catalog-backstage/LICENSE Adds the Apache-2.0 license.
bun.lock Registers both new workspaces.
Suppressed comments (2)

packages/adapters/catalog-backstage/test/source-scan.ts:147

  • The scan silently ignores executable .js, .jsx, .tsx, .mts, and .cts files even though the root TypeScript configuration allows JavaScript and nothing prevents these files from being added to the package. A dynamic loader placed in one of those files would bypass FR-002 while this guard remains green. Scan every executable source extension or explicitly fail when an unsupported source file is present.
    for (const entry of entries) {
      if (!entry.endsWith('.ts')) continue;
      const absolute = join(base, entry);
      const path = displayPath(relative(REPO_ROOT, absolute));
      if (EXCLUDED_FROM_SCAN.includes(path)) continue;

packages/adapters/catalog-backstage/test/source-scan.ts:205

  • This helper omits literal dynamic imports. Consequently, consumer code such as await import('../../adapters/catalog-backstage/src/index.ts') is invisible to both escapingRelativeImports() and undeclaredImports(), bypassing the claimed build-time/runtime no-edge boundary. Parse dynamic import('…') specifiers too and retain a negative case for a relative dynamic import from the consumer.
 * Matched on comment-stripped source. Covers `from '…'` (import and re-export,
 * including multi-line forms) and side-effecting `import '…'`.
 */
export function importSpecifiers(code: string): string[] {
  const specifiers: string[] = [];
  for (const pattern of [/\bfrom\s*['"]([^'"]+)['"]/g, /\bimport\s*['"]([^'"]+)['"]/g]) {

Contract: `package-boundary.md` §2, §4

- [ ] T009 [US9] **Observed failing.** Introduce a dependency edge from `@adrkit/core`
- [X] T009 [US9] **Observed failing.** Introduce a dependency edge from `@adrkit/core`
Comment on lines +342 to +346
// The clause is held instead by `bunfig.toml`'s `linker = "isolated"` — root
// level files get no `node_modules/@adrkit/`, so the same edge fails
// `bun run typecheck` with `TS2307: Cannot find module
// '@adrkit/catalog-backstage'`. That is Constitution Principle III's stated
// reason the isolated linker is load-bearing, observed working.
Comment on lines +101 to +105
`packages/core`, `packages/cli`, and `schema/` import nothing from it and must
not otherwise learn it exists
([ADR-0007](../../../docs/adr/0007-adapter-isolation-and-public-surface-build.md);
Constitution Principle III). Enforced by `bun run check:deps`, whose guard was
observed rejecting exactly that edge before it was relied on
Comment on lines +67 to +72
* Known limitation, stated rather than hidden: a regular-expression literal
* containing an unescaped `//`, or a lone quote character inside one, can put
* this scanner into the wrong state for the rest of that construct. The failure
* direction is a false negative. It is accepted because the alternative is a
* parser, and because {@link scanned} additionally asserts stripping never
* empties a non-empty file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants