Skip to content

Feature: Manifest as source - #124

Merged
jhweir merged 11 commits into
devfrom
feat/manifest-as-source
Aug 20, 2026
Merged

Feature: Manifest as source#124
jhweir merged 11 commits into
devfrom
feat/manifest-as-source

Conversation

@jhweir

@jhweir jhweir commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Manifest as source: one authored definition per model, everything else derived and verified

Summary

Until now every core model existed twice: a hand-written AD4M-decorated class, and the neutral
CORE_MANIFEST generated from it for the tooling that consumes models as data — the model
wizard, AI shape generation, interpretation hints, space-defined entities. Two representations of
one vocabulary, held in agreement by a test, with the generator's own comment admitting the
intended end-state ("the manifest is meant to become the source of truth"). This PR is that
end-state: each core entity is authored once, as a manifest module under
packages/models/src/manifest/ — schema, defaults, closed vocabularies, interpretation hints and
the design prose together — and everything else is derived from it: the decorated classes, the
TypeScript contract, and the model documentation in CLAUDE.md.

What the single source buys, concretely:

  • Stronger integrity checks than the hand-written classes ever had. A wrong predicate errors
    nowhere — it silently writes data where nothing looks for it. The manifest is now compiled by
    two independent code paths (the class generator, and the runtime compileManifest the wizard
    uses) and the equivalence suite compares their output exhaustively — predicates, cardinality,
    storage behaviour, hints, identity flags, per-instance defaults — so a bug in either path fails
    loudly instead of drifting. A prose-staleness suite does the same for the doc comments.
  • A compiler-checked model contract. Neutral per-entity interfaces are generated from the
    manifest, and conformance assertions hold the implementations to them at build time. This paid
    for itself before the PR was finished: it caught a wrong contract assumption (an instance
    update() that never existed), a missing HasManyMethods declaration the extraction had
    dropped, two pre-existing type lies that any had been hiding (myMentions.createdAt declared
    string, actually epoch numbers; block serialization typed against the wrong lane), and —
    the best catch — two missing statics on the test backend that made MutedAgent.delete()
    throw at runtime in any preview exercising muting.
  • Core and space-defined models converge on one pipeline. The wizard's models already lived as
    manifests; the core vocabulary now speaks the same IR, compiled by the same machinery, typed by
    the same query generics. One format to maintain, one set of invariants to test.
  • The interpretation hints — prompt payload, not documentation — are edited in one reviewed
    place
    , beside the schema they describe, instead of being scattered through decorator options
    across 28 class files.
  • Docs cannot drift from reality. CLAUDE.md's model section now derives from the manifest
    rather than from ts-morph parsing of class sources — what the docs say a model is, is what the
    manifest declares, definitionally. The switch immediately fixed an omission the old parser had
    (relation targets: location: HasOne → LocationBlock).

The flip was bootstrapped mechanically, not transcribed: the authored modules were extracted from
the generated manifest (data) and the hand-written classes (prose, optionality, bases, union
aliases), then verified identical to the old CORE_MANIFEST — sorted-key deep equality plus
the declaration order of every property map — before anything consumed them.

Changes

  • packages/models/src/manifest/ (new) — one authored module per entity: the schema
    (EntitySchema), the design prose (interpretation-hint essays included — they are edited here
    now), and codegen facts (CoreEntityDef in defs.ts): base class, TS-optional fields, union
    aliases like SignalMode with their values, accessor-method interfaces, typed relation arrays.
    shared.ts declares WeNode's five shared relations once; index.ts assembles CORE_MANIFEST,
    merging them into every WeNode-based entity exactly as the class hierarchy does.
  • The AD4M classes are generated, and live with the adapter that registers them
    @we/backend-ad4m/src/models/, rebuilt with pnpm --filter @we/backend-ad4m generate:classes.
    Same class names, same behaviour; doc comments are lifted from the manifest modules into the
    classes so IDE hovers keep working, and the emitter's output is lint-clean as generated, so a
    regeneration never dirties the tree. The only semantic-adjacent textual change is the removal of
    redundant initial: decorator options (SHACL derives initial values from field initialisers;
    the equivalence suite proves the schemas identical without them). The conformance assertions
    live beside the classes and are pulled into the build's type graph through the models barrel,
    so a class drifting from its interface fails the build.
  • packages/models reorganises around the manifest — it now holds the authored modules, the
    generated type contract (generate:typessrc/manifest/types.ts), the entity proxies
    (typed by that contract), the registries backends plug into, and the utils. Import path
    @we/models/generated/coreManifest becomes @we/models/manifest. Things that rode on the
    class files rehomed with their meaning intact: the SpacePreference sentinels and union aliases
    export from the manifest that declares them; DatasetProxy becomes the opaque handle it always
    was to its consumers (no member access existed anywhere).
  • @we/backend-shared/modelContract.ts (new) — the type-level companion to the query IR that
    package has always carried: ModelInstance, ModelStatic<T> (the static CRUD surface,
    update/delete statics included), and the typed-query generics (TypedWhere,
    TypedIncludeMap, IncludeExtras, …) that make
    findAll(p, { include: { $likeCount: … } }) return rows whose $likeCount is a number.
    The generics mirror the AD4M ORM's proven shapes, keyed off the manifest-generated interfaces —
    so dynamic entities and the test backend get the same typed query surface the hand-written
    classes had. Where the contract is looser (dataset handles opaque, write values tolerant of
    storage representations, timestamps uncommitted), that looseness is deliberate and documented.
  • The test/preview backend is compiler-verified. @we/backend-inmemory is the double the
    whole application runs on in tests and we-preview; its compiled entities now declare the same
    contract surface production code is typed against, pinned by a type-level assertion. Making the
    assertion pass exposed the missing delete/count statics above — exactly the class of gap a
    test double should not be allowed to have. Its file store keeps content inline as data URIs,
    which means previews get working image blocks for the first time.
  • The block persistence pipeline reads the manifest instead of decorator metadata. Which
    fields exist and which hold file content comes from CORE_MANIFEST (registrations carry their
    entity name); transactions run through runModelTransaction and file content through a
    registered ModelFileStore — capabilities the adapter registers beside its models; block
    classification became a class-scoped findOne that classifies and fetches in one call. This
    retired a whole failure mode the old identity test existed to pin ("stand-in resolves no
    metadata → blocks persist empty → posts come back blank"): metadata is data now, so it cannot
    be lost to class identity. The 39 pipeline tests run against plain fakes of the model surface —
    no @coasys mock — and the layering the package map documents for the block layer is now
    enforced by its actual dependencies. The reconcileBlocks call in SpaceStore loses its cast.
  • coreManifest.test.ts reverses its meaning: it now holds the generated classes and the
    manifest's runtime compilation in exhaustive agreement (two independent compilers, so one bug
    cannot hide in both). Staleness fails with the command that fixes it.
  • generatedClasses.test.ts (new) — prose staleness: every doc block the codegen lifts must
    appear in the generated class, whitespace-normalised.
  • CONVENTIONS.md, the package map, and CLAUDE.md updated to the new authoring flow: edit
    src/manifest/, run generate:types + generate:classes.

Known follow-ups

  • Static conformance for the AD4M classes specifically cannot be asserted structurally — the ORM's
    statics are this-polymorphic generics, and a detached method carrying that constraint
    satisfies no interface member. A small upstream typing change in ad4m (a declared static-surface
    interface) would enable it for every AD4M consumer; meanwhile the test backend's assertion plus
    the runtime suites carry the guarantee.
  • WeNode's hand-written interface declares a 'reactions' accessor for a relation that does not
    exist on the class — a pre-existing phantom, left untouched here, worth deleting separately.
  • The trailing closed-vocabulary comments (origin, role, preference, valueType) became doc
    comments; promoting them to manifest options (and generated unions) is a possible later step,
    pending confirmation that options stays out of the compiled SHACL.
  • Rebased onto dev after the model-authoring PR landed (nine commits, one generated-file
    conflict taken from dev and regenerated; the branch's interim formatting commit dropped as
    redundant with dev's own lint fixes). The base branch's final touch-ups — the reworked
    generation flow among them — are reflected in the regenerated context.

Test plan

  • Authored manifest verified deep-equal to the previously generated CORE_MANIFEST (sorted
    keys + property declaration order) before the flip.
  • coreManifest.test.ts — all 29 entities: schema summaries (predicates, cardinality, storage,
    transforms, hints, identity, flags) and per-instance field defaults identical between the
    generated classes and the manifest's runtime compilation.
  • Full monorepo, post-rebase onto dev: 2282 tests passing, zero type errors (explicit
    tsc --noEmit on app-shell, block-shared, block-solid, editor, backend-ad4m,
    backend-inmemory, models), build clean, we-validate-schemas clean, pnpm lint clean
    including regenerated output (both generators are lint-stable), ai-context regenerated from
    the manifest.
  • Block pipeline: 39 serialization tests against plain fakes; 204 backend-ad4m tests including
    equivalence, prose-staleness and shape-staleness suites at their new home.
  • Manual smoke in we-electron — weighted toward the block persistence path (compose a post
    with an image, edit it, delete it), since that pipeline changed transport underneath.

jhweir and others added 10 commits August 20, 2026 15:07
The first half of the manifest-as-source flip: every core entity's neutral
schema now exists as an authored module under src/manifest/, one file per
entity, carrying the design prose that previously lived on the decorated
classes — the interpretation-hint essays included, since those are the part
most worth reading where they are edited.

Definitions carry their own schema only; WeNode's shared relations (comments,
signals, participants, calls, mentions) are declared once in shared.ts and
merged at assembly, exactly as the class hierarchy gives them to subclasses.
Beside each schema ride codegen facts about the AD4M class it will generate —
base class, optional fields, union aliases, accessor-method declarations —
kept out of the neutral schema itself, which stays free of TypeScript.

Bootstrapped mechanically from the generated manifest and the class sources
rather than transcribed: a wrong predicate would not error anywhere, it would
silently orphan data. Verified identical to the generated CORE_MANIFEST —
sorted-key deep equality plus declaration order of every property map — before
anything starts consuming it. The generated file and the classes remain the
live source until the codegen lands in the next commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anifest

The second half of the flip, closing the loop the old generator's own comment
promised ("the manifest is meant to become the source of truth"). The decorated
classes under src/entities/ and src/blocks/ are now build artifacts of the
authored manifest modules — same paths, same export names, so nothing importing
them notices.

scripts/generateClasses.mjs emits each class from its definition: decorators
from the neutral schema (format 'file' → the file-storage language, readAs
'dataUri' → the transform, hints and identity riding through), TypeScript shape
from the codegen facts beside it (optionality, union aliases like SignalMode
emitted from their declared values, HasManyMethods interfaces, typed relation
arrays), and doc comments lifted from the manifest module — where they are
edited — into the class, where IDE hovers read them. The `initial:` decorator
option is gone from generated output: SHACL derives initial values from field
initialisers, so it was redundant where present and absent where not, and the
equivalence suite proves the schemas identical without it.

SpacePreference's AGENT_DEFAULT/FOLLOW_SPACE sentinels move to its manifest
module — real code cannot live in a generated file — and the class re-exports
them, so consumers keep their imports. The trailing closed-vocabulary comments
(origin, role, preference, valueType) became real doc comments in the manifest;
the CollectionBlock cleanup note and ThemeData rationale moved with the prose.

The old direction is dismantled: generateCoreManifest.mjs and the generated
coreManifest.ts are gone, '@we/models/generated/coreManifest' is now
'@we/models/manifest' (six import sites), and coreManifest.test.ts reverses its
meaning — it now holds the generated classes and the manifest's *runtime*
compilation in exhaustive agreement through two independent code paths, so a
codegen bug and a compiler bug cannot make the same mistake silently. All 176
backend-ad4m tests pass, including that suite; 2239 monorepo-wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The equivalence suite holds the semantics of the generated classes and the
manifest in agreement, but prose is invisible to it: a doc comment edited in a
manifest module without rerunning generate:classes would leave the class file —
where IDE hovers read it — telling the old story. Every doc block the codegen
lifts (the class doc and each member's, mirroring its own extraction) must now
appear in the generated class, whitespace-normalised; a stale generation fails
naming the command that fixes it. Deliberately not every comment in a module:
prose that belongs to the manifest alone, like the docs on SpacePreference's
sentinel constants, stays there on purpose.

Also formats the authored manifest modules to the repo's prettier shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, classes held to them

Completes the contract the flip implied. Until now the manifest was the source
of the schemas but the *types* consumers rely on still rode the AD4M classes —
the one implementation doubled as the definition. generate:classes now also
emits src/manifest/types.ts: one neutral interface per entity over a minimal
hand-written ModelInstance base (id, author, createdAt/updatedAt as unknown —
their representation is a backend's choice — save and delete; mutation is
assign-then-save, so there is deliberately no update). Fields only, also
deliberately: relation accessor methods and query sugar are backend ergonomics,
not the contract.

conformance.ts holds the generated AD4M classes to those interfaces with
type-level assertions, reached from the manifest entry point so the dts build
typechecks them — a class drifting from its interface fails the build rather
than waiting to be noticed. The guard caught a real contract error on its first
run: a base `update` method the classes never had.

A new backend now has a typed target: implement the interfaces the way
backend-inmemory runtime-compiles its own, register under the same names, and
consumers never notice. Relocating the AD4M classes out of @we/models was
considered and deliberately deferred — the block layer imports /classes, the
registry would cycle with backend-ad4m, and proxy typing would need ad4m's
typed include projections rebuilt; that is a PR of its own, recorded in the
summary doc.

Emitter output is lint-clean as generated, so a regeneration never dirties the
tree. Package map and CONVENTIONS updated; @we/models is no longer described as
"AD4M-decorated" but as what it now is: a neutral manifest with the AD4M lane
as one generated artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ies off the classes

The type-level completion of the flip. @we/backend-shared gains modelContract.ts
— the companion to the query IR it has always carried: ModelInstance (the
instance base every record satisfies), ModelStatic<T> (the static CRUD surface,
update and delete statics included), and the typed-query generics that make
findAll(p, { include: { $likeCount: … } }) return rows whose $likeCount is a
number. The generics mirror the AD4M ORM's proven shapes — they were structural
all along — keyed off the neutral interfaces instead of any backend's metadata.
Where the contract is looser, that is the neutrality: dataset handles are
unknown, write values tolerate backend representations (a file payload into a
storage field, an explicit updatedAt stamp), timestamps commit to no format.

The generated neutral interfaces gain the accessor trios consumers actually
call — addParticipants and friends on WeNodeModel, per-relation add/remove/set
where methodRelations declares them, setX for to-ones — because accessors are
contract where stores use them. The extraction had missed WeNode's own
HasManyMethods declaration; the compiler found it within minutes of the proxies
being retyped.

And the proxies ARE retyped: export type Space = SpaceModel, defineEntity as
ModelStatic<SpaceModel>. The @coasys classes are no longer the public type
surface of @we/models. Held to the zero-regression bar — full build plus
explicit tsc on the heavy consumers, zero errors — and the migration surfaced
two pre-existing lies that `any` had been hiding: myMentions.createdAt declared
string while hydration returns epoch numbers, and block serialization typing
its AD4M-lane work against the neutral stand-in. Both fixed at the source.

One limit, recorded rather than papered over: static conformance cannot be
asserted structurally, because AD4M's statics are this-polymorphic generics and
a detached method carrying that constraint satisfies no interface member. The
instance side is asserted; the static side is guaranteed at runtime, where the
proxy binds this at call time and the suite exercises the statics through the
same proxies production uses.

This also fells two of relocation's three blockers (no more dependency cycle,
no more typed-query loss). The last — block persistence behind a port — is the
genuine prerequisite, recorded in the PR doc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the contract

The inmemory backend's compiled entities now declare the neutral surface:
instances typed as ModelInstance plus the open remainder (truthfully — every
row is stamped with id/author/createdAt/updatedAt and hydration attaches
save/delete), findAll/findOne carrying the same generic include-projection
typing the contract declares, and a type-level assertion pinning
EntityClassLike to ModelStatic<ModelInstance>. This is the assertion the AD4M
lane cannot make structurally (its statics are this-polymorphic), so the
inmemory backend is where the contract is compiler-verified end to end — a
contract with one checked implementation is a description; with two it is a
constraint. It is also the test double for the whole application, so this is
where typing buys the most test fidelity per line.

Making the assertion pass surfaced a real hole, not a typing nit: the backend
had no static delete or count. Stores call MutedAgent.delete(dataset, id) —
which resolved to nothing here and threw at runtime in any preview or test
that exercised muting. Both implemented; delete of a missing id is a no-op,
matching the instance method.

The contract's update return widens to T | null — an update is a statement
about a record that must exist, the inmemory lane answers null for a missing
id, and the AD4M lane's Promise<T> narrows into it. No caller uses the return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The declare-const pair was half runtime: the declaration erases but the
assignment survives type stripping, so every test importing entities.ts threw
ReferenceError on a value that never existed. A Satisfies type alias asserts
the same thing and is erased everywhere; the dts build still checks it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The documented layering said block-system was agnostic; its persistence engine
said otherwise — serialization.ts read decorator metadata, called AD4M's
transaction static, wrote files through perspective.createExpression, and
classified blocks with an isSubjectInstance ASK, with @coasys declared in two
package.jsons. Every one of those now has a neutral answer, because the
manifest exists:

- Which fields an entity declares, and which hold file content, comes from
  CORE_MANIFEST — registrations carry the entity name the facts are looked up
  under. This is what frees the registry to hold the entity PROXIES rather
  than the AD4M classes: the old identity test pinned "must be real classes,
  stand-ins resolve no metadata"; with metadata out of class identity, the
  stand-ins resolve everything, and the test now pins the new load-bearing
  pair instead (registration ↔ manifest entity ↔ file-field markers).

- Transactions run through runModelTransaction, a runner the backend adapter
  registers beside its models (AD4M registers Ad4mModel.transaction; the
  default is a passthrough — writes land individually, atomicity is absent,
  which is the honest behaviour of a backend without batching).

- File content goes through a registered ModelFileStore. AD4M stores through
  its file-storage language; the inmemory backend keeps content inline as data
  URIs, which the resolve pass recognises as already-renderable — previews get
  working image blocks for the first time.

- Block classification is a class-scoped findOne per registered model: a find
  by id under class X answers null when the record is not an X, so the first
  non-null answer is the classification and the instance in one call —
  replacing an AD4M-specific ASK plus a second fetch.

The 39 pipeline tests now run against fakes of the neutral surface with no
@coasys mock at all, and @we/block-shared and @we/block-solid drop the
dependency from their manifests. Types follow: dataset handles are opaque,
instances are the contract base plus each type's declared fields. The
reconcileBlocks seam cast in SpaceStore is gone — the neutral store now hands
its neutral instance to a neutral pipeline.

This was the block persistence port the PR doc named as relocation's true
prerequisite — it turned out to be a registry, not a port: the capabilities
(transaction, file store) register beside the models, and the pipeline speaks
only the model contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s is purely neutral

The final structural step. The generated AD4M classes, WeNode, and the class
emitter move into @we/backend-ad4m (src/models, scripts/generateClasses.mjs) —
beside the adapter that registers them, which with the block layer neutral is
the only code left with a reason to hold a real class. @we/models keeps what is
neutral: the authored manifest, the generated type contract (a new
generate:types emits src/manifest/types.ts, union aliases like SignalMode
included), the entity proxies, the capability registry, and the utils — and
drops @coasys/ad4m from its dependencies entirely. The package map's row is now
**Agnostic** with nothing to footnote, and the dependency graph enforces it.

What had ridden on the classes rehomes: the SpacePreference sentinels and union
aliases export from the manifest that declares them; DatasetProxy stops
aliasing PerspectiveProxy and becomes what it always was to its consumers — an
opaque handle passed along, never opened (no member access existed anywhere);
the Ad4mModel type re-export dies and its one consumer types against
ModelInstance; the registry's ModelClass loosens to a structural handle, with
the AD4M lane narrowing at its own compiler boundary; signalAggregate types
against the neutral interfaces.

Conformance moves with the classes — asserted from the adapter's side now,
pulled into every build graph through the models barrel — and the equivalence
and prose-staleness suites follow them (204 backend-ad4m tests).

The model documentation in CLAUDE.md now derives from the manifest itself
rather than from ts-morph over class sources — what the docs say a model is, is
what the manifest declares, definitionally; a backend that never generates
classes documents identically. One visible improvement: relation targets the
old parser missed (HasOne → LocationBlock) now appear.

Emitted output lints clean as generated; both generators are stable under
regeneration. 2268 tests, zero type errors monorepo-wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The store surface changed under the rebase — the base branch's final touch-ups
reworked the generation flow (rows now stay collapsed after generating;
canAutoGenerateFields is gone) — and the generated context follows the rebased
source rather than either branch's snapshot of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit ea52074
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a871183471fef0008940cec
😎 Deploy Preview https://deploy-preview-124--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

…ct shape

ModelClass in @we/models is now a constructor plus an index signature — the
registry stores what a backend hands it, and what that class satisfies is
asserted where it is built. An index signature does not satisfy a *declared*
member, so this file's minimal structural ModelClass (whose whole content is a
declared `create`) stopped accepting the real getModel: we-preview's connector
failed to typecheck, the one host that passes it in.

The dep now takes what the registry actually returns and narrows once inside
applyFixture, keeping the internal typing (ModelInstance, .id, addChildren) and
leaving every host cast-free — which is what the comment this replaces was
protecting when Ad4mModel, with its real statics, was the registry's type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jhweir
jhweir merged commit 7623e70 into dev Aug 20, 2026
5 checks passed
@jhweir jhweir changed the title Feat/manifest as source Feature: Manifest as source Aug 20, 2026
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.

1 participant