Skip to main content

Tags

The tree rules decide visibility: which symbols the chains of exposures bring to each module. Test support must stay out of foreign production contracts, UI contracts must stay out of non-UI source, and browser code needs browser-safe runtime dependencies. These restrictions require the importing source area's classification and the exposed symbol's tags to be compatible, on top of everything the tree already decided.

Tags carry those facts, and they are purely restrictive: a tag can only withhold a symbol that is visible, never expose one. Ramify fixes two rule kinds. Its default tag registry defines testing, ui, dispatch, and browser; projects may add tags of either kind. The examples on this page use that default registry. The testing and browser diagrams below each use a small example universe and keep the tree trivial - two hops deep, everything exposed to the root and back to every descendant, so visibility is identical in every box by construction. The tag is the only variable in each picture. Their module boxes show ordinary-source availability; the nested src/tests/ profile is explained below.

testing - exposing internals to tests only​

In this small app, we keep order handling in an orders module. Its tests need an internal that must never become a production dependency: the resetOrderStore hook that clears the module's store between runs. Without tags we would face a false choice - expose the hook to everyone or to no one. testing resolves it: we expose exactly the internals tests may touch, tagged, through the ordinary channels.

So orders exposes two symbols to its parent - OrderService, the real contract, and resetOrderStore, tagged - and app exposes both to its descendants, where integration-tests is a testing module beside the production consumer billing. Identical chains, identical arrivals - and one symbol wears a tag.

Traced symbols■ stopOrderService - the default contract: every module the exposure reaches may import itresetOrderStore - same chain, tagged testing: available only in testing modulesOrderServiceOrderServiceresetOrderStoreresetOrderStoreappapp rootreceives▼OrderServicefrom orders▼resetOrderStore⇥ testingfrom ordersordersowns▲OrderService▲resetOrderStore⇥ testingbillingreceivesOrderServicefrom appresetOrderStore⇥ testingfrom app⇥ testingintegration-testsreceivesOrderServicefrom appresetOrderStore⇥ testingfrom appNode▲exposed to parent▼exposed to descendantsTags⇥rule: available only in modules carrying the same tag⇤rule: only symbols carrying the same tag are available in the modulenamevisible here, not availableBoth symbols are visible everywhere; the ⇥ testing chip alone decides where the tagged one is available.Tags never expose: the testing module imports nothing the tree did not re-expose to it - it sees OrderService because the chain reaches it.Test support is curated symbol by symbol, and a symbol is real contract or test support, never both.Exposing to descendants is safe at any subtree width: the availability rule travels with the symbol, so it is not available in untagged modules anywhere the exposure reaches.
drag to pan · scroll to zoom

(The diagrams are live, exactly as on the core model page: click a traced symbol to isolate its chain, drag to pan, scroll over a figure to zoom.)

Every symbol has a tag set, assigned once by its owner and immutable from then on: no module along the way can add, remove or change a tag, which is why resetOrderStore's chip rides on the owner's row and on every arrival alike. A module can carry tags too, declared in its own definition - integration-tests is a tagged module, and wears its tag as the dashed box filling the node.

A module's tags classify its ordinary source under src/, including src/interfaces/. Its nested src/tests/ has a separate testing profile. Ordinary subdirectories share their source area's profile; separately declared submodules own their files and declare their own tags. Tags are not inherited across ownership boundaries. A *.test.ts filename outside src/tests/ does not confer testing classification.

And a tag is not just a name: each registry definition selects one of the two rule kinds fixed by Ramify. Project-defined tags follow those same rules. That is why the diagrams write a tag behind the glyph of its rule - ⇥ testing - the name and the rule are one thing. testing carries the required importer tag rule, written ⇥, and that rule fires from the symbol: the demand rides out with the symbol and is checked where it lands - the symbol is available from exposure only in source areas carrying the same tag.

Read billing's box: resetOrderStore is listed, provenance and all, with its name struck - visible there, not available: you can see it, you cannot have it. The same strike appears in app's own box: app carries no tag either, and re-exposing a symbol earns no right to import it. In the testing module the name is plain. Selecting a tagged symbol shows the same verdicts in motion: exactly the arrivals where it is available blink, and withheld rows stay dark.

On a source area, testing classifies its files as testing source, and every new exported binding defined there must carry testing. An explicit tag set that omits it is invalid. This applies to a testing module's src/ and to every module's src/tests/. A production-defined reset hook can also carry testing for its cross-module contract while remaining usable within its owner's production source.

Received symbols keep their owner's tags. A testing module re-exposing a production symbol does not turn it into test support.

Two properties make this small mechanism enough. There is no global test channel: resetOrderStore reaches the testing module because the chain of exposures brought it there, not because the importer is a test. And exposing to descendants preserves this testing restriction at every subtree width: the availability rule travels with the symbol and withholds it from non-testing source areas everywhere the exposure reaches.

Tests within their module's source​

Each module owns src/, with optional test and interface directories inside it; separately owned children live under subs/:

orders/
├── module.ramify
├── src/ # this module's source
│ ├── interfaces/ # ordinary contract vocabulary
│ └── tests/ # this module's tests and helpers
└── subs/ # separately declared child modules

Ordinary source and src/tests/ have one owner. The testing profile takes precedence within src/tests/; the module header classifies the rest of src/, including src/interfaces/. The interface directory adds no separate profile or automatic exposure. Tests can import their own implementation's internal exports without exposure declarations. Foreign symbols still need an exposure path, and a parent's tests gain no automatic access to a child's private internals.

The test profile is fixed: testing plus every required-importer tag in the module header. Required-symbol tags are not inherited, and the profile cannot be overridden or extended. For example:

ramify 1
module widgets tagged [ui, browser]

Here browser implementation and Node/jsdom tests share ownership. The src/tests/ profile is [testing, ui]. Each source area has one profile; individual filenames do not override it.

A module tagged [ui, dispatch, browser] similarly has [testing, ui, dispatch] in src/tests/. The same derivation applies to project-defined tags according to their rule kind.

Tests needing additional tags use a separate testing module under subs/. For example, browser-classified tests can use:

ramify 1
module browser-tests tagged [testing, ui, browser]

Put these tests in that module's ordinary src/, where all three tags apply. Its optional src/tests/ would still derive [testing, ui]. The separate module needs explicit exposure and compatible tags for the implementation it tests; it gains no automatic access to its parent's private exports. An owner can expose a small testing-only helper or wrapper where needed. Forwarding aliases retain the original tags.

Non-testing source cannot import, re-export, or load testing source, including within the same owner and through a testing barrel. This source restriction also covers type-only imports. Forwarding preserves the original symbol's identity and tags; it cannot hide a testing source origin.

ui - keeping UI contracts out of core source​

ui carries the required importer tag rule, just like testing. An exposed UI symbol requires ui on the importing source area for both value and type-only imports. A shared component may reach every branch through an ancestor's exposure while remaining unavailable to non-UI core.

Every exported binding owned by a UI module must carry ui, including its test bindings. A test area tagged ui also requires it on its new exported bindings. Received symbols keep their original tags.

ui classifies architectural coupling; browser classifies runtime safety. For an exposed component tagged [ui, browser]:

Importing areaValue importType-only import
UI implementation [ui, browser]AllowedAllowed
Node UI tests [testing, ui]AllowedAllowed
Non-UI core []DeniedDenied
Browser-only utility [browser]DeniedDenied

These examples assume the component is visible and its import path passes the testing-source restriction. A UI tag supplies no additional reach.

dispatch - keeping transport coupling explicit​

dispatch also carries the required importer tag rule. A foreign transport contract or typed client tagged dispatch requires a dispatch-classified importer for both values and types. Its exposure path still decides where that contract can reach.

A connected view can carry [ui, dispatch, browser], while a pure UI module uses [ui, browser]. The connected view can import visible dispatch contracts; the pure UI cannot. Every newly owned export of dispatch source must retain dispatch, including its test bindings. Forwarded symbols keep their original tags. The tag does not prove that a function only dispatches; implementation responsibilities need separate review.

browser - keeping Node code out of the browser​

Our second app has a ui module that runs in the browser, a server module beside it, and a shared module both of them draw on. Browser code must not pull in Node-only dependencies - and that line runs through shared, not between modules: shared owns both formatMoney (browser-safe) and queryDb (Node-only). Splitting every such module in two would multiply modules to encode one fact; browser states the fact per symbol instead.

The universe is the mirror image of the last one: here the module wears the working tag, and the rule asks the symbols for a match.

Traced symbols■ stopqueryDb - untagged: visible in the browser module, not available thereformatMoney - tagged browser: the promise the browser module asks forqueryDbqueryDbformatMoneyformatMoneyappapp rootreceives▼formatMoney⇤ browserfrom shared▼queryDbfrom sharedsharedowns▲formatMoney⇤ browser▲queryDb⇤ browseruireceivesformatMoney⇤ browserfrom appqueryDb∗from appserverreceivesformatMoney⇤ browserfrom appqueryDbfrom appNode▲exposed to parent▼exposed to descendantsTags⇥rule: available only in modules carrying the same tag⇤rule: only symbols carrying the same tag are available in the modulenamevisible here, not availablename∗not available, still type-availableThe mirrored rule: here the importing module carries the tag, and the rule asks the symbol for the same one.The browser line is drawn per symbol, not per file or module: shared never splits to keep browser-safe and Node-only code apart.Selecting queryDb: server blinks and ui stays dark - its struck row and the strike say the same thing.The tag is a promise, not a proof: importability consults the declared browser, and a false claim is reported at the owner, never at the importer.
drag to pan · scroll to zoom

browser carries the required symbol tag rule, written ⇤ - the mirror of testing's. Where testing's rule fires from the symbol, this one fires from the importing source area. On a source area, browser classifies files that run in a browser, and the demand is checked on everything arriving: that area may value-import from exposure only symbols tagged browser. On a symbol, browser is the other side of that demand - a falsifiable promise that the symbol's entire transitive runtime closure is browser-safe, the owner's private files included. The chip still wears the rule's glyph, because the name and the rule are one thing, but nothing fires there. The promise is verified separately, and a false claim is reported at the owner - never at the importer, which did nothing wrong by believing it.

Read ui's box: it is a browser module - the same dashed box as the testing module, labeled ⇤ browser - and queryDb's row in it is struck: visible there, not available, like billing's struck row in the last picture. Selecting queryDb shows the dynamic version - server lights up and ui stays dark.

The general shape​

Two availability rules exist, and the diagrams showed one of each: the required importer tag (⇥, used by testing, ui, and dispatch) rides out with the symbol; the required symbol tag (⇤, browser's) is checked for the importing source area. When several rules apply to the same symbol and source area, all of them must be satisfied. And tags never expose: no rule makes an import possible that the exposure chain did not already allow. A tag can only withhold a symbol that is visible.

All modules in a check share one tag registry. Project-defined tags choose one of the two kinds; they cannot introduce new matching algorithms, implications, or exceptions. New owned exported bindings must carry all required-importer tags of their defining source area, including unexposed exports, new wrappers, type aliases, and resource bindings. Required-symbol tags are assigned explicitly. Registering a tag alone does not classify any source or export.

Unknown tags, conflicting definitions, and per-module overrides are errors. Only testing has a reserved structural role: it must retain its required-importer kind, its connection to src/tests/, and production-to-testing isolation. The other default tags use the same matching and propagation as project tags.

Type imports​

The diagrams distinguish ordinary value imports from type-only imports. A value import can put another module's code in your bundle. TypeScript's other form, import type, is erased at compile time and carries no runtime dependency, and it gets one refinement of its own: a symbol is type-available in a source area when that area's files may import it as a type. The diagrams show each module's ordinary source profile.

The two verdicts can only differ one way - available always implies type-available, never the reverse (the specification decides "no rule is type-only"). So the full chain is:

available ⊆ type-available ⊆ visible

Whether a rule exempts type imports follows from what its claim is about:

  • browser's claim is about runtime - "this closure is safe to execute in a browser." An erased import executes nothing, so the rule covers value imports only. queryDb is therefore struck in ui (not available) yet still type-available there - the unstruck ∗ after its struck name is this fact: ui may reference its signature - runQuery: typeof queryDb - without ever bundling the implementation.
  • testing's claim is about coupling - "this is not part of the production contract." A production function typed in terms of a test fake violates that at the type level just as thoroughly as at runtime, so the rule covers both forms: resetOrderStore in billing is not even type-available.
  • ui also restricts coupling: non-UI source cannot depend on a UI contract through either value or type-only imports.
  • dispatch restricts coupling too: transport-independent source cannot import dispatch-tagged contracts in either form.

An interface or type alias imported without a type modifier also receives a type-only availability check when its resolved original has no runtime binding. It must still pass exposure, required-importer tags, and testing-source isolation. A runtime-bearing class or function needs an explicit type-only import to be exempt from the browser rule; later type-only usage is insufficient.

These checks do not prohibit symbol-free initialization imports across owners. Known testing-source restrictions still apply. Source checking checks explicit namespace and lazy-import selections and reports unsupported portions as nonblocking coverage notes by default; definite violations still fail.

The diagrams give all of this exactly one character: the strike means "not available", and an unstruck ∗ after a struck name marks the middle band of the chain - type-available but not available. Everything else about type imports lives here in prose, where it can be explained rather than drawn.

Where the precision lives​

This page teaches the tag rules. The complete, authoritative rules, including tag composition and type-only imports, are in Cross-Module Importability Principles at docs/model/cross-module-importability.principles.md in the repository. Its companion, docs/model/glossary.md, defines the vocabulary reproduced in the glossary. The website must conform to these two internal documents.