Skip to content

Latest commit

 

History

History
485 lines (362 loc) · 34.8 KB

File metadata and controls

485 lines (362 loc) · 34.8 KB

aimeta — Specification

This document captures the implementation contract for the aimeta q module: the annotation standard, the runtime model, the JSON schema, and the public API surface.


Goals

  1. Make every kdb+ process self-describing for agents. Tables, columns, types, foreign-reference graphs, and (where the operator opts in) gateway functions with signatures, descriptions, and call examples.
  2. Zero-annotation baseline. Loading the module against an existing process must produce immediately useful metadata. Annotations are additive enrichment, never a precondition.
  3. One canonical form, one direction of flow. Annotations in source compile to meta.json, which is loaded read-only at runtime. No mutation API; no live drift between source and served metadata.
  4. Multiple consumer tiers, one schema. HTTP (GET /meta), qIPC (.aimeta.get*), and the kx-meta CLI all see the same meta.json shape.

Non-goals

  • A live metadata editor. Annotations are source-of-truth.
  • A query layer. meta.json describes capability; it does not execute it.
  • An access-control layer. Filtering by identity/role belongs in the gateway and the Trust Layer, not here.

Runtime model

.q source files with / @ annotations
        │  aimeta compiler (built into .aimeta; runs on every init[] by default)
        ▼
.aimeta/meta.json  ← canonical form, deterministic output
        │  .aimeta.init[]  (called by host script at startup)
        ▼
.aimeta.data  ← read-only in-memory dict
        ├── GET /                          → home document (cold-start landing)
        ├── GET /.well-known/api-catalog   → RFC 9727 Linkset (discovery)
        ├── GET /meta                      → streams raw JSON bytes (no re-encoding)
        ├── GET /openapi.json              → OpenAPI 3.1 description of routes
        ├── GET /meta.schema.json          → JSON Schema for /meta's body
        └── .aimeta.get*[]                → dict / table lookups (Tier 2 qIPC)

Direction is one-way. Source is canonical. JSON is a deterministic output; in-memory state is read-only. To change anything, edit annotations and recompile.

Compile on every boot, by default. init[] recompiles the source tree on every host start. srcDir defaults to the directory containing .z.f, resolved against the OS's notion of cwd (system "pwd" — not getenv \PWD``, which subprocess.Popen does not update). The compile-speed work in M6 established that compile is fast enough at realistic scales: ~80ms for the demo, sub-500ms for a 30-table tickerplant.

Opt out with --nocompile. Production hosts that ship pre-built metadata (e.g. compiled in CI, deployed alongside the q binary) launch with q host.q --nocompile. init[] then skips the compile step and reads .aimeta/meta.json directly. D2 is the worked example of this flow.

Degradation chain. A bad source tree must never crash a running host, and an unannotated tree should not produce a useless empty /meta. init[] walks the chain in order, never propagating an error past the chain:

  1. Compile succeeds AND emits tables or functions → write .aimeta/meta.jsonloadJson[]. Normal path.
  2. Compile succeeds but emits zero tables AND zero functions → fall through to Tier-1 in-process introspection. The host's tables[], meta t, and \f .ns produce a structurally-useful doc that is served instead of the empty-arrays compile output. Stamped with process.compileStatus:"empty" so consumers can detect the degraded source. Typically means the source is unannotated; annotating fixes it.
  3. Compile fails AND .aimeta/meta.json exists → serve the prior file (known-good beats synthesis). Compile only writes on success, so a rule-violation rebuild leaves the prior artefact untouched.
  4. Compile fails AND no prior file → Tier-1 synthesis as in (2), but stamped with process.compileStatus:"failed" and process.warnings carrying the captured compile error.
  5. Tier-1 synthesis itself errorsloadJson[] on the missing path falls through to empty metadata, with a WARN line. Host stays up.

Footgun: init[] placement for unannotated hosts. If you add aimeta to an un-annotated file, call init[] at the end of the script, after your table and function declarations. The Tier-1 synthesis in step (2) introspects the live namespace — only symbols bound before init[] runs are visible. This won't matter once you add annotations, because the compile path in step (1) reads the source file directly; placement becomes conventional, not load-bearing.

Documented failure mode. Hosts loaded by a parent script (e.g. a test runner that does \l demo/host.q from a different directory) get the parent's directory as srcDir, not the demo's. The fallback chain catches it — compile finds nothing actionable, the on-disk file or tier-1 path takes over. An explicit srcDir override knob is not shipped; revisit if the failure mode bites in practice.


Module layout and public surface

The module ships with four components, each in its own file, loaded by init.q:

Component File Responsibility
metadata metadata.q Runtime data dict, JSON loader, qIPC read API
compiler compiler.q Annotation parser, validator, JSON renderer
rest rest.q HTTP handlers for GET /, /.well-known/api-catalog, /meta, and the OpenAPI/Schema sidecars
discovery discovery.q discover[] probe library backing the kx-meta CLI

init.q exports a deliberately narrow surface:

Export Source Purpose
compile compiler.q Build meta.json from a directory of .q source files
discover discovery.q Programmatic probe — also reachable via kx-meta discover
init init.q Load meta.json; register /meta handler
data metadata.q Read-only runtime dict — getter (data[])
getTables metadata.q qIPC: list of tables
getTable metadata.q qIPC: one table by name
getFunctions metadata.q qIPC: list of functions
getFunction metadata.q qIPC: one function by name
getReferences metadata.q qIPC: list of reference-vocabulary entries
getReference metadata.q qIPC: one reference entry by semanticType

Internal-only symbols (annotation parsing helpers, HTTP framing) stay private to their respective files. Argv parsing and subcommand dispatch live in scripts/kx-meta.q, outside the module.


Tag reference

The compiler recognises a closed set of tags. Unknown tags are warnings, not errors — annotation evolution must not break existing files.

Tag Applies to Required? Description
@desc function, table yes for @public Single-paragraph natural-language description
@public function opt-in Marks a function as part of the published surface
@private table opt-out Excludes a table from the published surface
@param function per-param name {type} description — type in qdoc syntax
@returns function yes for @public {type} description — return type and shape
@example function recommended Concrete, working call example. Treated as opaque text — never executed by the compiler
@uses function recommended Space-separated table names this function reads from (e.g. @uses trade quote). May repeat across multiple lines; the union is the dependency set. Drives the publish graph and cross-process docs.
@col table per-col name {type} description — overrides or augments native meta output
@sampleRow table optional, repeatable One row per tag — comma-separated q literals (e.g. 2026.04.29D14:30:00,`AAPL,175.5,100). Cell count and per-cell type must match @col. See §"@sampleRow cell grammar".
@reference table opt-in Argument is a vocabulary name. Marks the table as the canonical local resolver for any column with a matching @semanticType. See §"Reference resolution".
@semanticType column optional Vocabulary tag (e.g. currency, instrument) for cross-table joins. Pairs with @reference to declare resolver tables.
@foreignRef column optional tableName.columnName — declares a foreign-reference graph edge
@cardinality column optional low, medium, high — informs query planning hints
@attr column optional, repeatable Each value is one of s/u/p/g — the kdb+ column attribute (sorted/unique/parted/grouped). @attr:u marks the column as the @reference resolver key. Repeating on a @col line collects every value into the column's attributes list. See code.kx.com/q/ref/set-attribute.
@label column opt-in Marks a column as a human-readable label for the row. Multiple @label allowed; first-declared is primary. Only meaningful on @reference tables.
@tag function, table optional Free-form labels (e.g. experimental, deprecated)

Scope and visibility

  • Tables are public by default. Every table in tables[] appears in meta.json, with native meta as the fallback when no annotations exist. @private opts a table out.
  • Functions opt in via @public. Unannotated functions and helpers do not appear. @uses declares the table dependency graph for the function and is used to propagate cross-process documentation.
  • @uses resolves transitively. A @public function's @uses table pulls that table into the published surface even if the table is in another process — the cross-process publish graph is walked at compile time.

Reference resolution

Reference data (instruments, exchanges, currencies, calendars) is the connective tissue agents need to translate user-facing names into query terms and back. The spec models this with two cooperating tags:

  • @reference X on a table declares it as the canonical local resolver for vocabulary X. The table must have a column with @attr:u (the resolver key).
  • @semanticType X on a column declares its values are drawn from vocabulary X. When a @reference X table exists in the same meta.json, the column is implicitly joinable to it on the unique-key column.
  • @label on a column inside an @reference table identifies the human-readable name for each row. Multiple @label columns may be declared; the first wins for display, all are surfaced for lookup.

The compiler emits a denormalised top-level references[] index so consumers can enumerate reference vocabularies without scanning every table.

The @reference X@semanticType X link is separate from @foreignRef. @foreignRef declares a single explicit edge between two specific columns; @reference + @semanticType declares a vocabulary-level relationship that any column can participate in. Both may appear on the same column.

@sampleRow cell grammar

One line per sample row, comma-separated q literals. The compiler splits on commas, parses each cell with a literal-only grammar, and assembles a 98h table — value/parse are never called on annotation text.

Accepted — one literal per cell:

  • Numeric: 100, -50, 100i, 25h, 175.5, 1.5f, 2.5e, 2.5e+3.
  • Boolean 1b, 0101b; byte 0xff, 0x0102.
  • Symbol `AAPL, `AAPL`MSFT, `; string "hello" (commas inside must be "..."-quoted; escapes \" \\ \n \t \r).
  • Temporal: date 2026.04.29, month 2026.04m, minute 14:30, second 14:30:00, time 14:30:00.123, timestamp 2026.04.29D14:30:00, timespan 0D14:30:00.000000000, datetime …T… (legacy).
  • Guid 1234abcd-1234-1234-1234-123456789abc.
  • Null/infinity for every type: 0N, 0n, 0Nh0Nn, 0w, 0W, 0Wh0Wn, and their - variants.

Rejected — by character class, not heuristic: any ( ) [ ] { } ; @ $ \\ * / % & | = < > ! ~ ^ ? # ' _ outside a string/symbol; identifiers and .z.* (no name lookup); compound shapes (dicts, nested lists, lambdas, calls).

A cell that would be a dict, table, or other compound shape is omitted (the column stays absent from the sample) and documented via @desc. A future @sampleJson may cover full-row JSON if this proves too restrictive.


meta.json schema

{
  "schemaVersion": 2,
  "compilerVersion": "0.1.0",             // semver; build of the compiler that produced this file
  "process": {
    "name": "gateway",                  // optional — informational
    "host": "...",                      // populated only when running
    "port": 5013                        // populated only when running
  },
  "tables": [
    {
      "name": "trade",
      "private": false,
      "desc": "...",                    // optional
      "reference": null,                // optional — vocabulary name if this is an @reference table
      "labels": [],                     // optional — ordered label-column names; populated when reference != null
      "columns": [
        {
          "name": "sym",
          "kdbType": "s",
          "desc": "...",                // optional
          "semanticType": "instrument", // optional
          "foreignRef": "instrument.sym",// optional
          "cardinality": "high",        // optional
          "attributes": ["g"],          // optional — list of "s"/"u"/"p"/"g"; field omitted when none
          "label": false                // optional — true if @label
        }
        // ...
      ],
      "sampleData": [...],              // optional, q-table-shaped
      "tags": []                        // optional
    }
    // ...
  ],
  "references": [                       // computed top-level index
    {
      "semanticType": "instrument",     // vocabulary name
      "table": "instrument",            // resolver table name
      "keyColumn": "sym",               // @attr:u column on the resolver
      "label": "name",                  // primary @label column (null if none)
      "labels": ["name", "longName"]    // all @label columns, primary first
    }
    // ...
  ],
  "functions": [
    {
      "name": ".gw.vwap",
      "desc": "Time-bucketed VWAP for one or more instruments",
      "params": [
        {"name": "syms",   "type": "symbol[]", "desc": "..."},
        {"name": "dt",     "type": "date",     "desc": "..."},
        {"name": "bucket", "type": "timespan", "desc": "..."}
      ],
      "returns": {"type": "table", "desc": "..."},
      "examples": [".gw.vwap[`AAPL`MSFT; .z.d; 0D00:05:00]"],
      "uses": ["trade", "quote"],   // multiple tables — space-separated in source, array here
      "tags": []
    }
    // ...
  ]
}

Determinism: keys must appear in stable order, no timestamps, no environment-dependent values. A clean recompile of unchanged source must produce a byte-identical file.

A worked example covering every tag in this spec lives at tests/fixtures/worked/host.q (annotated source) and tests/fixtures/worked/meta.json (the compiled output it should produce). The compile tests exercise the same fixture.


Schema versioning

schemaVersion (top-level, currently 2) is a single integer marking the shape of meta.json. It changes only on breaking edits to the schema; consumers use it to fail loudly when reading a file they don't understand.

Version history

Version Change
2 Removed column field unique and the @unique tag. Added column field attributes (string list) and the @attr tag covering s/u/p/g. @attr:u replaces @unique for @reference resolver-key declaration.
1 Initial schema.

What counts as a bump

Bump when an edit would silently misinterpret an old file or break a current consumer:

  • A field is renamed (e.g. references[].key → keyColumn).
  • A field is removed, or its type changes (string → object, scalar → array).
  • A required field becomes optional, or vice versa.
  • The structure of a section changes (e.g. tables[] becoming a keyed object).

Don't bump for additive changes:

  • A new optional field on an existing object — old consumers ignore it.
  • A new optional tag in §"Tag reference" that produces a new optional field.
  • New entries in an enum where consumers already pass values through.

Pre-1.0 caveat: while no external consumers exist, breaking edits may land without a bump if every in-tree consumer is updated in the same MR. The keyColumn rename was such a change. Once the schema is consumed outside this repo, that exception ends.

Consumer contract

A consumer reading meta.json must inspect schemaVersion before trusting any field:

  • schemaVersion == known: read normally.
  • schemaVersion < known: the consumer may read it if it chooses to maintain backwards compat. Backwards compatibility is a per-consumer decision, not a spec requirement.
  • schemaVersion > known: fail loudly. Print the file's version, the consumer's max-known version, and a pointer to upgrade. Do not best-effort parse — fields may have moved.

metadata.q (the in-process loader) and scripts/kx-meta.q (the CLI) both follow this contract. New reader libraries should declare a MAX_SCHEMA_VERSION constant alongside their parser.

Bump procedure

A schema bump lands in a single MR that:

  1. Updates schemaVersion in compiler.q.
  2. Updates the schema example and §"meta.json schema" prose.
  3. Updates every fixture and golden under tests/.
  4. Updates MAX_SCHEMA_VERSION in every reader library shipped from this repo.
  5. Adds a CHANGELOG entry naming the version, the breaking change, and the upgrade path for downstream readers.

Compiler version

Distinct from schemaVersion. compilerVersion is a semver string identifying the build of the compiler that produced a given meta.json. It tracks the implementation; schemaVersion tracks the wire shape. The two move independently — most compiler releases will not bump schemaVersion.

  • Source of truth: the compilerVersion constant in compiler.q, alongside schemaVersion. Re-exported from init.q's export dict so the CLI can read it.
  • Stamped: into every meta.json as a top-level field next to schemaVersion.
  • Surfaced: via kx-meta version, which emits {"compilerVersion":"X.Y.Z","schemaVersion":N}.
  • Bump cadence: when the compiler's output or behavior changes meaningfully — parser, validator, or JSON renderer changes that produce a different meta.json for the same source; new validation rules; etc. Decoupled from the project release version (which lives in CHANGELOG.md): a release that doesn't touch the compiler leaves compilerVersion alone, so existing checked-in meta.json artifacts stay byte-identical. Bumping is a one-line edit in compiler.q plus a golden refresh (UPDATE_GOLDENS=1 q test.q); no MR-wide reader-library coordination is required.

Consumers reading meta.json should not branch on compilerVersion — it is informational, useful for bug reports and reproducibility, not a compatibility gate. Compatibility decisions go through schemaVersion.


Public API

kx.aimeta.compile[srcDir]

Walks srcDir for .q files, parses annotations, validates against the schema, and writes srcDir/.aimeta/meta.json. Returns the written path on success. Validation errors block the write and signal with all rules collected; warnings print to stderr but don't block.

kx.aimeta.validateDir[srcDir]

Validation entrypoint without the write. Returns `errors`warnings!(strings; strings) keyed by severity. Used by kx-meta compile --check for CI gating.

kx.aimeta.init[]

Compiles the host's annotations on every boot by default (override with the --nocompile launch flag), loads the resulting .aimeta/meta.json into the runtime data dict, registers the GET /meta HTTP handler, and binds the read API at .aimeta.* (init, reload, setlvl, data, getTables/getTable, getFunctions/getFunction, getReferences/getReference) so qIPC clients can call h ".aimeta.getTable[\t]"` without the host re-binding the export dict by hand. Idempotent — safe to call again after a recompile. Degrades gracefully through the four-step chain described in §"Runtime model".

kx.aimeta.data[]

Returns the read-only runtime dict. Shape mirrors meta.json. Mutation is not supported; bypassing the read-only contract is a bug. Exposed as a no-arg getter rather than a bare binding: q destructuring at use time snapshots dict values, so a plain data entry would freeze callers at the empty dict captured before init[] runs.

kx.aimeta.getTables[], getTable[name], getFunctions[], getFunction[name]

qIPC read API for Tier 2 consumers. Same data as data, scoped lookups.

kx.aimeta.getReferences[], getReference[semanticType]

qIPC read API over the computed references[] index. getReferences[] returns the full list; getReference[\currency]returns the single entry whosesemanticType` matches, or an empty dict if none.

kx.aimeta.discover[host:port; opts]

The CLI entrypoint. Probes the target process from Tier 3 down to Tier 1 (HTTP GET /meta → qIPC .aimeta.get* → native tables[] / meta / \f), returning a meta.json-shaped dict with a tier field indicating which tier resolved.


HTTP framing

GET /meta returns 200 with Content-Type: application/json and an ETag: header. The ETag is the md5 hex of rawJson, recomputed on every loadJson[]/reload[]. Clients that send If-None-Match: <etag> get 304 Not Modified (no body) when the ETag matches; otherwise the full JSON. The hash is opaque to clients — they only round-trip it.

POST /meta returns 405 Method Not Allowed with Allow: GET. Other paths return 404 Not Found unless a pre-existing .z.ph/.z.pp claims them — register[] wraps any handler the host installed before module init, so non-/meta traffic delegates through. PUT/DELETE/OPTIONS to /meta are not dispatched to user callbacks by q's HTTP layer; q returns its default 501 Not Implemented. This is acceptable behavior; cleaning it up would require either patching q's HTTP layer or running behind a proxy.

Body emission is single-shot via plain string concatenation. Typical .aimeta payloads are 1–100KB; chunked Transfer-Encoding is not implemented and not planned for sub-MB payloads.

Discovery sidecars

The module serves four static documents alongside /meta so the route is discoverable to cold clients without out-of-band knowledge:

Path Body Purpose
/ JSON home document First-hop landing page for cold clients that probe the root. Lists every discovery URL the host serves.
/.well-known/api-catalog RFC 9727 Linkset (application/linkset+json) IETF standards-track discovery entry. Anchored at / with link relations pointing at the OpenAPI document, JSON Schema, and /meta.
/openapi.json OpenAPI 3.1 document Describes every HTTP route the module serves, so agent frameworks and Swagger UI can bind to a known entrypoint.
/meta.schema.json JSON Schema (draft 2020-12) Validates the body returned by /meta. The OpenAPI document $refs it for the /meta 200 response.

The home document has the shape {"service":"aimeta","schemaVersion":N,"links":{"meta":...,"openapi":...,"schema":...,"apiCatalog":...}}schemaVersion is sourced from meta.schema.json at module load and tracks any future bump automatically. The Linkset uses the relations service-desc (OpenAPI), describedby (JSON Schema), and item (the meta payload), all anchored at /.

All four sidecars use the same framing as /meta (200 + ETag; 304 on If-None-Match match; 405 on POST). Content-Type is application/json for the home document and the OpenAPI/Schema, application/linkset+json for the api-catalog. Their bytes never change at runtime, so ETags are computed once at module load.

Source of truth for the OpenAPI doc and JSON Schema lives in aimeta/openapi.json and aimeta/meta.schema.json, checked into the repo. rest.q reads them directly from disk at module-load time using .Q.rp \:::file.json(the kdb-x convention for resolving paths against the loading module's directory). The home document and Linkset are constructed in q at module load — no on-disk source. When the schema bumps, updateinfo.versioninopenapi.jsonandproperties.schemaVersion.constinmeta.schema.jsontogether withcompiler.q's schemaVersionopenapiVersionLockedToCompiler enforces the lockstep, and the home document inherits it transitively (homeDocSchemaVersionLockedToSchema`).


Discovery tier stack

Tier What's available Functions surface Tables surface
3 — deluxe .aimeta + HTTP GET /meta — full JSON: signatures, descriptions, examples Schemas, semantic types, foreign refs
2 — enhanced .aimeta + qIPC .aimeta.getFunctions[], .getFunction[f] .aimeta.getTables[], .getTable[t]
1 — introspect any kdb+ + qIPC \f namespace + value .namespace.fn for arity (best-effort) tables[], meta, namespace scan

The kx-meta CLI must work against any kdb+ process — including legacy processes with no .aimeta installed. At Tier 1, the output is structurally useful but semantically sparse: column names and types, but no descriptions, examples, or function annotations. Per-function arity is best-effort: if value .namespace.fn errors on a particular binding (locked-down host, unbound global), we omit arity for that function and continue. The CLI embeds a "tier" field so consumers can calibrate expectations.


Compiler validation rules

The compiler enforces these rules; violations are errors collected and surfaced together.

  1. Every @public function has @desc and @returns.
  2. Every @param declared on a function corresponds to a real argument.
  3. @uses references resolve to a known table — either local (in tables[]) or declared elsewhere in the source tree.
  4. Each @sampleRow should have one cell per @col, each a q literal whose type matches the column's kdbType. A wrong cell count, a type mismatch, or a non-literal cell produces a warning and drops that table's sample — the rest of the table still compiles (sample data is illustrative, not load-bearing).
  5. @example is treated as opaque text; the compiler does not parse, type-check, or execute it. (Treating it as opaque is a deliberate decision — examples may include illustrative shorthand or non-runnable forms.)
  6. @foreignRef is tableName.columnName syntax; both must exist somewhere in the source tree (warning if not, not error).
  7. @reference X requires the table to have a column with @attr:u — without a key, the table cannot resolve.
  8. Two tables declaring @reference X for the same X is an error — the resolver must be unambiguous.
  9. @reference X whose vocabulary X is not used by any @semanticType in the source tree produces a warning (probably typo, but isolated reference tables are legitimate).
  10. @label on a column whose table has no @reference produces a warning — nothing consumes it.
  11. @label on a non-symbol/non-string column produces a warning — labels are expected to be human-readable text.
  12. Unknown tags produce warnings; duplicate tags on the same target produce warnings unless explicitly allowed (e.g. multiple @example, multiple @label).
  13. @attr:X value must be one of s/u/p/g — anything else is an error. Multiple @attr modifiers on a single @col line are valid; each value is checked individually. Conflicting kdb+-impossible combinations (e.g. s + p) are not flagged — the schema models declared intent and is permissive about the runtime constraint.

Deferred to v2

Decisions parked beyond the preview release.

  • @sampleRow sensitivity policy. Real data in source files raises governance questions. Either restrict @sampleRow to synthetic illustrative data only or add a CI check that flags suspicious values (real CUSIPs, real PII shapes).

Compiler decisions (M3)

Calls made during the M3 build that resolve specific Open Questions and lock conventions for downstream phases. The compiler lives in aimeta/compiler.q.

Parser: kx.ax.qdoc via internal API

The compiler delegates annotation parsing to the kdb-x kx.ax.qdoc module, but uses its internal pipeline (.z.m.qd.lib.genTagBlocks via .z.m.qd.pre.file.toArtifacts) rather than the public surface. The public (use \kx.ax.qdoc)`qddict exposesdoc(rendered markdown),getFiles, and getTags` — none returns the structured per-item tagBlocks aimeta needs. We accept the version-coupling risk: ax internal-API drift is patchable.

Required: @kind and @name on every annotated item

qdoc's preprocessor only surfaces items declaring @kind function or @kind data (tables use @kind data). Without @kind, the item is silently dropped.

qdoc auto-detects name from the binding line for some patterns but fails for multi-line lambda bodies. The compiler reads names from @name only, ignoring qdoc's name and module columns. By convention, @name carries the binding-name-as-written: leaf for top-level (@name trade) and fully-qualified for namespaced bindings (@name .gw.vwap).

Section-divider comments above unrelated bindings can produce phantom rows from qdoc with @kind but no @name. The compiler filters those at projection time.

Identity tagmap for aimeta-specific tags

qdoc strips the tagType of unknown tags (returning empty `` ), preserving the line content but losing the tag identity. The compiler registers each aimeta tag in qdoc's tagmap` as an identity mapping, so every `tagType` survives the pipeline. The full set lives in `compiler.q`'s `META_TAGS` constant.

Type-char mapping defers to q's type system

For @col columns the compiler emits a single-char kdbType (e.g. "s" for symbol). The mapping evaluates `<typeName>$() and indexes .Q.t for the char — q's own type table is the source of truth. Two qdoc names that aren't valid q cast verbs are aliased: bool → boolean, string → char. Unknown types pass through verbatim for the validator to flag.

@param and @returns keep the qdoc string verbatim ("symbol[]", "table") — q functions are dynamically typed and have no kdb-char form.

Determinism via insertion order, not sorted keys

The original plan called for sorted-key JSON output; the implementation achieves byte-determinism through q's insertion-order-preserving dicts. projectTable and projectFunction build their dicts in a fixed order matching the SPEC schema, .j.j walks them in that order, and no timestamps or environment reads enter the output. Recompile of unchanged source is byte-identical, which is the underlying intent. The on-disk key order is the SPEC-documented order, not alphabetical.

Multi-file discovery: recursive

compile[srcDir] walks srcDir recursively (qdoc's default). The SPEC's open question on whether to skip tests/ is left unresolved; projects that need to exclude can do so via qdoc's exclude setting when aimeta exposes one. For now, point compile at a directory that contains only intended source.

process.name configurable; host/port runtime-only

compile stamps process.name from the PROCESS_NAME module constant (default "host"). Hosts can rebind before calling compile. host and port are SPEC-marked as "populated only when running" and the compiler does not stamp them — those are runtime concerns handled by the loader, not compile-time facts.

Tag namespacing: align with ax/qdoc, no @aimeta:* prefix

aimeta's net-new tags (@uses, @col, @sampleRow, @reference, @semanticType, @foreignRef, @cardinality, @attr, @label, @tag) keep plain names rather than carrying an @aimeta: namespace prefix. Audit at decision time: none of them appears in ax/qdoc's tag table (.z.m.qd.data.TAGS), and the shared tags (@desc, @public, @private, @param, @returns, @example, @kind, @name) carry consistent or refining semantics — no parser-level or semantic conflict exists.

Parser-level survival of aimeta-only tags is handled by registering each one in qdoc's tagmap as an identity mapping — see "Identity tagmap for aimeta-specific tags" above.

If a future ax release introduces a colliding tag, we coordinate at that point. The cost of a prefix on every annotation in every annotated codebase outweighs the hypothetical conflict.

Tag registry: inline SUPPORTED_TAGS, migrate past ~20

The supported tag set lives in compiler.q's SUPPORTED_TAGS constant — a single q symbol list. At 19 distinct tags (16 declared plus kind/name/overview inherited from qdoc) the inline form remains readable on one screen and reviewable in a single MR. No per-tag documentation files yet; the table at §"Tag reference" above is the canonical user-facing list.

Migration trigger: once SUPPORTED_TAGS would exceed ~20 entries, move to one markdown file per tag under docs/tags/ with frontmatter (applies-to, required, semantics, examples) and a build step that compiles them into the constant. The threshold is a guideline — adding the 21st tag is the point at which "split this into a registry" stops feeling premature.