Skip to content

All notable changes to @tv/extension-sdk.

The format follows Keep a Changelog; the SDK uses Semantic Versioning from 1.0 onward.

[Unreleased] ​

Added ​

  • tvRemoteCss() in @tv/extension-sdk/vite (experimental): a Vite plugin that confines a federated module's built CSS to the module (ADR 160). A remote's stylesheet is appended to the Building OS document, and its Tailwind utilities merge into the shell's @layer utilities. Loaded later, a remote's .hidden beats the shell's lg:flex, and one remote's .flex-col beats another's sm:flex-row. Measured on the dev stand on 2026-09-30, 25 of 29 deployed remote stylesheets overrode at least one shell style.
    • The plugin rewrites every emitted .css asset so each rule matches only inside the element Building OS mounts the remote in (data-tv-remote="<federation name>"), or outside the shell's root, which covers dialogs and menus and a module's standalone page.
    • The added condition sits inside :where(), so specificity and the module's own cascade do not change. No class name changes; module source is untouched.
    • Rules that declare only custom properties (design tokens, Tailwind's @property fallbacks) stay global. @keyframes, @font-face and @property are left alone.
    • Pass the same name the module gives the federation plugin: tvRemoteCss({ name: federationName }). A name that is not a plain identifier is refused when the config loads.
    • A module can adopt it before Building OS carries the matching attributes: until then the second condition matches the whole page and the module renders as it does today.
    • Also exported: scopeRemoteCss(css, name), scopeSelector(selector, name), REMOTE_ATTRIBUTE, SHELL_ROOT_ATTRIBUTE.
    • Cost: the built stylesheet grows. For the deployed CAFM sheet, 122 KB became 218 KB raw, and 18.6 KB became 20.6 KB gzipped.
    • Inside a dialog or menu, stylesheets from different modules still merge, as they do today.
  • postcss is now a runtime dependency, used by the plugin to parse the built CSS.

[1.18.0] — 2026-09-28 ​

The first release published since 1.15.1. 1.16.0 and 1.17.0 were versioned here but never published to the registry (the tv-platform gitlink that publishes stayed at 1.15.1), so installing 1.18.0 brings their changes too: the 1.17.0 McpSignatureGuard change in particular refuses every request in production when no signing key is configured.

Added ​

  • Platform events in a federated module (experimental): usePlatformEvent(), subscribePlatformEvent(), createMockPlatformEvents(). A module had no way to receive platform events. PlatformContext.eventBus travels through React Context, which does not reach a remote, so usePlatformContext().eventBus threw inside Building OS, and the events guide showed exactly that code. Building OS now puts events.subscribe() on window.__TV_PLATFORM__ (tv-building-os#225), and these wrap it:
    • usePlatformEvent(type, handler) in @tv/extension-sdk/react subscribes while the component is mounted, and calls the latest handler without resubscribing.
    • subscribePlatformEvent(type, handler) in @tv/extension-sdk/context does the same outside React and returns an unsubscribe function. isPlatformEventsAvailable() says whether a shell delivers events. PLATFORM_EVENT_TYPES lists the seven types.
    • Payloads are typed (PlatformEventPayloads): building.* from the event catalog, and the four wifi-sensing.* events from the fields tv-api's gateway sends.
    • Scope: events arrive only for the building selected in the shell, and only when the user's permissions allow that data (tv-api#273). Each subscriber gets its own copy. Nothing can be published.
    • Outside a shell, or in a shell that predates the channel, subscribing is a no-op.
    • createMockPlatformEvents() in @tv/extension-sdk/testing installs a stand-in with emit() for a module's own tests. It refuses the types the shell refuses and copies payloads the same way.
    • PlatformGlobal gains an optional events.
  • createPlatformApiClient() in @tv/extension-sdk/context (experimental): a PlatformApiClient a federated module can actually get. PlatformContext.api travels through React Context and does not reach a remote, so inside Building OS a module had no PlatformApiClient, and createBuildingGraphClient() takes one. The README's own graph example got it from usePlatformContext(), which throws in a remote. The new client runs over fetch, asks getAccessToken() for the token on every request, and sends paths relative to the page, where Building OS serves /api in production and in sandboxes. So createBuildingGraphClient(createPlatformApiClient()) works inside the shell, and so does api.get('/api/v1/…') for any other endpoint.
    • Query values that are undefined are left out.
    • Bodies are sent as JSON, except FormData, Blob, URLSearchParams and ArrayBuffer, which go as they are.
    • Per-request headers override the client's.
    • An empty answer resolves to undefined, and a non-JSON one to its text.
    • Any non-2xx answer throws PlatformApiError, with status, method, url and the parsed body; its message includes tv-api's message (validation messages joined).
    • The options (baseUrl, getToken, headers, fetch) and the error's fields are @experimental; the returned PlatformApiClient is the stable interface.
  • graph.buildings.get(buildingId) returns BuildingDetail: the building with its organisation, site, building type, storeys ordered by level and _count, which is what GET /api/v1/buildings/:id answers. The matching key is buildingGraphKeys.buildingDetail(id), nested under buildingGraphKeys.building(id) like the rest.
  • A module's sidebar label can be translated: ui.navigation[].labelLocales and top-level nameLocales in the manifest. Both take a map from language tag to text, e.g. { "pt-BR": "Ordens de servico" }. The rest of the platform already supports them, and only this schema refused them:
    • tv-api's registry stores both at ingest and returns them.
    • Building OS picks the user's language, then its base language (pt for pt-BR), then the plain label.
    • Before this, tv-sdk validate, and sandbox install, which validates first, rejected a manifest carrying them (Unrecognized key: "labelLocales"). A module outside Building OS cannot add to the shell's own translations, so its sidebar label stayed in one language whatever the user chose.
    • Keys must be language tags (ru, pt-BR), and values are non-empty. manifest.schema.json carries both fields, so editors autocomplete them. The new LocalizedStrings schema and type are exported from @tv/extension-sdk/manifest.

Fixed ​

  • The event catalog describes what tv-api actually sends for building.point.updated and building.alarm.triggered. Both schemas described payloads tv-api has never sent, over NATS or over its browser socket. Code written against them read undefined, and validateEventPayload() rejected every real event. Checked against tv-api 5511bbb: TelemetryService, FddService, and the /buildings gateway's field lists (src/gateway/browser-events.ts, tv-api#273).
    • building.point.updated is now { buildingId, pointId, value, timestamp }.
      • value is a string, because tv-api stores readings as sent ("21.5", "on").
      • timestamp is when the reading was taken.
      • Gone: observedAt, quality, unit and elementId, none of which tv-api sends.
    • building.alarm.triggered is now { buildingId, faultId, severity, title }.
      • faultId is the fault's id, as in GET …/faults.
      • severity is the fault's own open string (critical, high, medium, low, …), as on building.fault.created.
      • Gone: alarmId, code, message, acknowledgedAt and the info/warning/critical/emergency enum.
    • The exported PointUpdatedPayload and AlarmTriggeredPayload types change with them. That breaks compilation only for code that read fields no event ever carried. No module in the organisation uses either type, and no manifest declares a payloadContract on either event (checked 2026-09-28).
    • tv-events.snapshot.json is regenerated. check-events against the old snapshot reports these corrections as breaking, which is the point of the regeneration.
    • building.element.status_changed and building.changeset.applied keep their schemas and now say they are not published: nothing in tv-api raises either. The note on building.element.status_changed also says that newStatus will carry one of tv-api's element statuses (ACTIVE, INACTIVE, MAINTENANCE, DECOMMISSIONED), with no FAULT or DEGRADED: an equipment fault arrives as building.fault.created (tangovision/tv-api#275).
  • create, list, connect, extend, delete and reset now resolve the provisioner's address and the developer's credential the same way install --sandbox has since 1.15.1 — never TV_API_URL, and TV_API_TOKEN only when it is a developer key (tvk_…). 1.15.1 fixed this for install --sandbox alone; the other six subcommands went through a second, older copy of the resolution in client.ts that still fell back to TV_API_URL and any TV_API_TOKEN. sandbox connect already told developers not to export the TV_API_URL / TV_API_TOKEN pair it prints, precisely because doing so broke those six commands — the warning was correct, but the six commands it warned about were still the ones with the bug. resolveSandboxApiUrl / resolveCredential (client.ts) are now the one place every subcommand resolves this, and each calls the new warnAboutIgnoredProvisionerEnv once, before its first request, to say what it is ignoring and why — the same class of warning install --sandbox already gave, extended to the rest of the family. sandbox connect's own guidance and the root --help text are updated to describe the fixed behaviour.

Changed ​

  • openapi.snapshot.json is regenerated against tv-api 8662c18 (2026-09-28), through pnpm export:openapi. The previous snapshot was exported without @nestjs/swagger's compiler plugin, and tv-api#263 had not yet declared the building graph's responses. So the document published to module authors had changed in several ways:
    • Responses: the seven read endpoints a module uses (the building, and the list and single GET of storeys, spaces and elements) now declare their response schemas: 29 new response schemas and 4 enums. Operations with no documented response body fell from 250 of 255 to 158 of 311.
    • Query parameters: 142 optional query parameters that the old export dropped (limit, offset, storeyId and the other list filters) are now listed.
    • Endpoints: the 56 endpoints added to tv-api since the last export are included.
    • No breaking changes: compareApiSpec(old, new) reports none. It reports three info items, from/to on history/diff and pointId on telemetry/observations, which were always required and are now documented.
  • The graph wire types follow tv-api's response DTOs (tv-api#263, graph-responses.dto.ts).
    • Space list and detail type geometry as GeoJsonPolygon | null; it was Record<string, unknown>.
    • Element list, detail and view=graph items gain location: GeoJsonPoint | null.
    • Element gains siteId, plotId and roadSegmentId: tv-api returns them on every element, and tv-building-graph does not model them yet, so they are layered on in the wire types as ElementAnchorFields.
    • ElementDetail gains the site, building, plot and roadSegment references.
    • New exports: GeoJsonPolygon, GeoJsonPoint, BuildingDetail, BuildingType, ElementAnchorFields.
    • These are response types, and every added field is one tv-api already sends. Code that reads them is unaffected. A test fixture that builds one of these objects by hand needs the new fields.
  • CI type-checks the test files. Nothing did before: tsconfig.json excludes *.test.ts, *.test.tsx and *.spec.ts, so pnpm typecheck never read them, and vitest strips types without checking them, so every expectTypeOf assertion (the Serialized and "wire types follow tv-api" tests in src/graph/client.test.ts) passed whatever it asserted. The new tsconfig.test.json extends tsconfig.json over all of src/, tests included; pnpm typecheck:test runs it, and the test job in ci.yml runs that after the build. A plain tsc run rather than vitest's typecheck mode, because that mode checks only the files it is pointed at, and the errors this found were in an ordinary test fake no type assertion touches. Verified by breaking the Serialized assertion: typecheck:test failed with TS2344 while vitest run still passed the file.
  • The axios fake in src/graph/client.test.ts did not match AxiosLikeInstance: its methods returned Promise<{ data: { ok: string } }> where the interface's generic methods return Promise<{ data: T }>, a TS2345 at both platformApiFromAxios() calls. Its methods are now generic, with the canned body cast to T. AxiosLikeInstance is unchanged. No change to the published package.
  • scaffold-canary's pnpm install of a freshly scaffolded module now fails the job if pnpm reports "Issues with peer dependencies". Until now pnpm install only warns on an unmet peer and exits 0, so a scaffold that regresses to an unresolved peer range — the same class of defect a version conflict in the generated package.json produces, which is exactly what this job exists to catch (see the job's own comment on the @tanstack/react-query omission in 1.14.0) — read as a green canary. The install's output is captured and grepped; a match fails the step with the log already printed.

Removed ​

  • tv-sdk copilot is gone. Hidden from --help since 1.15.1 but still running underneath, it called a model that has been retired (claude-3-7-sonnet-20250219), offered module categories the scaffolder rejects, and executed model-chosen shell commands and file writes without asking. Nothing outside src/copilot/ and the one dispatch branch in cli.ts referenced it, so it is removed rather than repaired: tv-sdk copilot now prints a clear "no longer available" message and exits 1, instead of running. @anthropic-ai/sdk, @inquirer/prompts, chalk, gradient-string and ora drop from dependencies — nothing else in the package imported any of them. Its future belongs to the AI module creator work, which is in planning, not to this CLI.

[1.17.0] — 2026-09-28 ​

Security ​

  • McpSignatureGuard refuses every request in production when no signing key is configured (ADR 154, part 1). With neither TV_PLATFORM_SIGNING_SECRET_<SLUG> nor TV_PLATFORM_SIGNING_SECRET set, the guard used to log a warning and return true for every request, signed or not, in every environment, although its own docstring said it refused to start in production. Any deployment that forgot or had not yet sealed its key was an unauthenticated MCP surface, and so was any McpAccessGuard (the tv-module-events shape) that hands its HMAC half to this guard. It now behaves exactly as tv-api's own src/common/auth/mcp-signature.guard.ts:
    • NODE_ENV=production and no key: every request is answered 401 with code MCP_SIGNING_SECRET_MISSING, and the first refusal is logged once at error level, naming the variable to set. The process still starts and /health stays green; it is the MCP route that refuses.
    • Any other NODE_ENV (including unset) and no key: the request is still accepted unsigned, with a warning, because tv-api's executor sends a timestamp but no signature when it has no key either.
    • A configured key: unchanged. What a correctly configured module accepts and refuses is identical, so this is not a wire change.
    • Before bumping a module to this release, confirm its deployment declares a key by name. Every on-prem module backend mounts the shared TV_PLATFORM_SIGNING_SECRET today, except notifications-api, whose TV_PLATFORM_SIGNING_SECRET_NOTIFICATIONS comes from a Secret that is not sealed yet; on this release it would refuse its only caller until that Secret exists. Modules pick the release up only through an explicit lockfile change, so nothing changes until each is bumped.
  • The per-module variable name uses tv-api's normalisation. The slug is upper-cased and every run of characters outside [A-Z0-9] becomes one _; only - used to be replaced, so the guard and tv-api's executor agreed on the variable name for kebab-case slugs only. Every slug passed to the guard across the organization and every per-module name the GitOps manifests declare (TV_PLATFORM_SIGNING_SECRET_NOTIFICATIONS) maps to the same name under both rules, measured 2026-09-28.

[1.16.0] — 2026-09-27 ​

Added ​

  • OccupantScopeService.resolve() forwards the caller's tenancy selection (ADR 145, part 4). One person can be a contact of several tenancies (a franchisee with two shops, an accountant for several), and the leases module now lets each request select one with an X-Tenancy-Id header, checked against that person's own rows (tv-module-leases, ADR 145 parts 1 to 3). A module only sees that selection if it passes it on, so resolve() now also takes the incoming request, resolve(req), and forwards both its Authorization and its X-Tenancy-Id to GET /api/tenant/scope; passing the request means neither header can be forgotten. The scope keeps its single-tenancy shape.
    • A request without the header sends none, and leases answers with the caller's default tenancy, exactly as before. The old form, resolve(req.headers.authorization), still works and sends no selection; resolve(authorization, tenancyId) passes one explicitly.
    • A selection that is not the caller's is 403 from leases and 403 here (This account is not linked to the selected tenancy). It is never retried without the header: a person who selected B must not be shown A. A blank header counts as none; a repeated one is forwarded as it came, so leases refuses it rather than this side picking a value.
    • New exports: TENANCY_HEADER ('x-tenancy-id') and the OccupantRequest type.
    • Rollout (ADR 145): leases and this release first, then service desk and accounts receivable change their one call to resolve(req), then the portal's switcher. A module still on 1.15 resolves the caller's default tenancy: their own data, but possibly not the tenancy they picked. Staff callers are unaffected; occupant scope is never consulted for them.

Changed ​

  • permissions.snapshot.json is regenerated against tv-api 06b33e7 (2026-09-27). Two changes. tv-api#268 removed tenant from engagement.promotions:write, which is now granted to manager and marketeer only; tenant keeps engagement.promotions:read. The snapshot from tv-api 2734d4c also lacked thirteen actions the registry gained since: attendance.policies, attendance.records, attendance.scans, attendance.shifts and attendance.staff (eight actions), building.copies:read/write, and guard-patrol.alerts:write, guard-patrol.config:write and guard-patrol.patrols:read. No action was removed. tv-platform's permission-catalog.yml compares this file against the pinned tv-api registry; the check was red on the pointer-sync pull request (tv-platform#823).

[1.15.1] — 2026-09-26 ​

Fixed ​

  • tv-sdk login works on Windows. Windows Credential Manager caps a secret at 2560 bytes (@napi-rs/keyring counts the password as UTF-16), and a Keycloak access + refresh token pair is larger, so saving the session threw Value of 'password encoded as UTF-16' is longer than the platform limit of 2560 chars after the device flow had already succeeded. The file fallback covered only a keyring that could not be opened at all, never one that refused a single value. A rejected save now writes ~/.config/tv-sdk/credentials.json with the usual warning and removes any older keyring entry; load reads the file whenever the keyring has no entry, since the next process picks the keyring again; logout removes both; and a session that fits the keyring again deletes the stale file. TV_SDK_TOKEN_STORE=keyring keeps a rejection a hard error. The warning no longer claims 0600 permissions on Windows, where the file is guarded only by the user profile's ACL.
  • The src/Shell.tsx that init-module generates no longer opens with an unused import React from 'react'; the generated tsconfig uses the automatic JSX runtime ("jsx": "react-jsx").
  • The generated vite.config.ts pointed readers at docs/platform-standards/MODULES.md, a path that exists in no repository a module author has. It now links the published federation guide, https://developers.tango.vision/guide/federation.
  • The shim comment in the generated vite.config.ts said "the shell-integration smoke in CI is what would catch its removal". An --external module's CI has no smoke step (its own ci.yml says so), and the file is identical in both variants, so the comment now names both catches: the smoke where CI runs it, a sandbox shell where it does not.
  • The generated CHANGELOG.md ended its Sales Impact note with see AGENTS.md, "CHANGELOG Required", a section no generated AGENTS.md has. The pointer is gone; the rule it pointed at is stated in the note itself.
  • sandbox install --sandbox reaches the provisioner even when the sandbox connect values are exported. The lookup found the provisioner through TV_SANDBOX_API_URL, then TV_API_URL, and authenticated with TV_API_TOKEN ahead of the login session — and TV_API_URL / TV_API_TOKEN are exactly the pair sandbox connect printed as export lines, which the developer walkthrough ran one line before install. With them applied, the lookup went to the sandbox's own host carrying the sandbox's own token, and failed at the last step before a module first renders (reproduced against a local stub: GET /api/v1/sandboxes/sbx_abc auth=Bearer sandbox… on the sandbox stub). The lookup now finds the provisioner only through --api, TV_SANDBOX_API_URL or the deployed default, and authenticates with --token, else TV_API_TOKEN only when it holds a developer key (tvk_…), else the tv-sdk login session. A TV_API_TOKEN it skips, and a TV_API_URL it does not use, each get a one-line warning on stderr saying so. The warning calls a skipped token the sandbox's own only when it carries the tvs_sbx_ prefix the provisioner mints; any other value is reported as not a portal key, with no guess at what it is. The rest of the sandbox commands still read both variables as before.
  • --token passed with --sandbox is no longer sent to the sandbox. It authenticated the lookup and was then also used, ahead of the record's own apiToken, for the registry-ingest and licence calls — so a tvk_ key passed that way was a guaranteed 401 on ingest. Those calls now always use the sandbox's own token from its record.
  • sandbox install errors name the step and the service that refused, and no longer tell the developer to ask us. 1.15.0 answered every 401/403 — a mistyped or revoked portal key at the lookup included — with "The sandbox tv-api rejected your credential … This is expected today … ask for the module to be registered for you, and keep serving the remote — nothing else in the loop depends on this call". That limitation ended when sandboxes began accepting their own token on ingest (2026-09-19), the lookup is not the sandbox tv-api at all, and without this call the module never appears. A refusal at the lookup now reads "The provisioner at … rejected your developer key (TV_API_TOKEN) (401) while looking up sandbox …" and says to mint a new key in the portal or run login (a sandbox token passed as --token is named as the mistake; a 403 repeats the provisioner's own scope message). A refusal from the sandbox's tv-api says whether the token came from the sandbox record or was a developer key sent there directly, and what to do in each case. --format=json errors keep step and add stage (lookup / ingest / buildings / licence), service and url.
  • sandbox <subcommand> --help prints the usage and exits 0. It answered "Unknown flag: --help" with exit 2 — including for sandbox create --help, the command sandbox list itself suggests when you have no sandboxes. The connect line of the usage no longer promises environment "for tv-sdk dev", a command that does not exist.
  • The scaffolded README no longer calls the sandbox shell what makes running a module safe. It said a sandbox shell bound to your own sandbox "is what makes running it safe", an isolation guarantee the platform does not make. It now says only what holds: the sandbox shell is the only shell that loads a module from localhost, and it is separate from any customer's production shell. It also says the token sandbox connect shows is for server-side code, never src/ or a commit.
  • The standalone page of a scaffolded module no longer goes blank the moment the Shell calls useQuery. src/main.tsx wraps the tree in a QueryClientProvider — outermost, where Building OS has its own — so the first example in the developer guide renders in pnpm dev instead of throwing "No QueryClient set, use QueryClientProvider to set one". The comments say the host provides the real one, that a Shell must never mount its own, and that @tanstack/react-query joins the federation shared list once the Shell uses it. The remote itself is unchanged: the Shell does not import react-query, so the query client lives only in the standalone entry's chunk.
  • A scaffolded module installs without peer-dependency warnings, and has no lint script that cannot run. The generated package.json pinned i18next ^25 against react-i18next 17's peer of >= 26.2.0, and @tv/ui ^1.4.1 against this SDK's peer of >= 2, so the first pnpm install of every module warned about our own packages; i18next is now ^26.2.0 and the first-party scaffold carries @tv/ui ^2.2.0 and @tv/design-tokens ^3.2.0 (the version @tv/ui 2 depends on). "lint": "eslint ." came with no ESLint and no config, so pnpm lint failed with "eslint: command not found" in every fresh module; the script is gone, and the README and the external AGENTS.md say pnpm typecheck is the static check.

Changed ​

  • init-module scaffolds for Node 24. Both generated workflows ran actions/setup-node at node-version: '22' while the generated package.json declared engines.node: '>=20.0.0' — three different floors across the toolchain, none of them the one the platform actually assumes. The shell-integration smoke needs Node >= 22.15 for module.registerHooks (below it the harness fails loudly rather than skipping), and Building OS itself builds on node:26-alpine, so 20 was never a floor anything verified. engines.node is now >=24.0.0 and both workflows run 24, with a comment tying the three numbers together so they move as one. Generated output only: existing modules keep their own pins.

    The pnpm pin is deliberately untouched. It is a platform constant owned by tangovision/infrastructure/.github/actions/setup-pnpm (10.18.2), which build-push.yml compares every Dockerfile against, so raising it here alone would turn each new module's first build red while leaving the fleet on 10.

  • Every sandbox command that takes an id says where to find it. connect, extend, delete, reset and install --sandbox= take the sandbox id (sbx_…), which is not the slug in the sandbox's URL, and the developer portal lists sandboxes by name only — a developer who had lost the create output got Missing required argument: <id> or a bare HTTP 404 and nothing more. The sandbox --help text now says what <id> is; a missing id, and a 404 for the given one (the provisioner's answer both for an unknown id and for another account's), add that create prints the id and npx @tv/extension-sdk sandbox list shows every id you own. JSON output carries the same text as hint.

  • The human output of sandbox create, list and connect shows the sandbox's slug and organizationId. They are what the sandbox-login hand-off needs, and until now they reached the terminal only through --format=json. A value the provisioner has not set yet prints as —, not null.

  • sandbox create ends with a ready-to-paste hand-off line, Please set up sandbox logins for slug=<slug> (organizationId=<id>), addressed to your Tango Vision contact or developers@tango.vision, followed by the sandbox install … --sandbox=<id> --remote-entry=… command on one line, so it pastes into PowerShell and cmd as well as bash (a \ continuation, pasted into PowerShell, runs without --remote-entry). Its last line used to be sandbox connect <id>, the command whose output was the trap above. The sandbox --help example and the scaffolded README give the command on one line too.

  • sandbox connect prints TV_API_URL / TV_API_TOKEN as server-side settings, not shell exports, and keeps the token out of browser code. They are printed as KEY=value lines for server-side code only (a script, a local backend, tv-sdk ingest --api=… --token=…). The token is the sandbox's administrator token, so the output says never to put it in browser code — a module's frontend calls tv-api with getAccessToken() / authorizationHeader() from @tv/extension-sdk/context, the signed-in user's token — and not to commit either value. It also says not to export them in the shell tv-sdk runs from, and that install --sandbox needs neither.

  • Scaffolded modules declare "packageManager": "pnpm@10.18.2", the platform pin the fleet's own package.json files already carry. Corepack installs that version, and a newer pnpm switches itself to it (measured: pnpm 11.18.0 runs 10.18.2 inside the scaffold), so a developer who installed pnpm 11 globally still installs with the version the generated CI and AGENTS.md require. The external CI keeps its explicit version: 10.18.2; pnpm/action-setup fails the job if the two ever disagree.

  • init-module --external no longer gives a partner's module Tango Vision's identity. Without --scope (or with --scope=tv) it warns that the id and package name will be under @tv; the manifest gets no author unless --author names one, instead of "Tango Vision" (an --author-email or --author-url without --author is refused rather than filed under Tango Vision); the default descriptions and the standalone page title say "Building OS" rather than "Tango Vision"; the generated CHANGELOG.md does not ask for Tango Vision's Sales Impact copy; and package.json drops @tv/ui and @tv/design-tokens, which nothing in the scaffold (or this SDK) imports. init-module prints the id and author it wrote, and --author / --description are in --help.

  • The --external scaffold cites nothing its author cannot open, and promises no gate their repository does not run. .gitignore said "the gitleaks gate in build-push.yml blocks a committed secret anyway" — an external repository has neither — and now says nothing scans for secrets and to add a scanner. ADR 015, 091 and 103 and the private tv-module-* repositories in vite.config.ts, README.md, AGENTS.md, ci.yml and pnpm-workspace.yaml are replaced by the matching public pages on developers.tango.vision, or by the rule itself. The shared Dependabot hold comment no longer promises a shell-smoke step the external CI does not have. First-party output keeps its references. The code a shell runs is the same in both variants; scaffold.test.ts lists the files that differ.

  • init-module's next steps pass --sandbox <your-sandbox-id> to sandbox install, which otherwise stops with "Cannot tell which sandbox to install into". The root --help lists install among the sandbox subcommands.

  • tv-sdk copilot is no longer listed in --help. It still runs, unsupported: it calls a model that has been retired, offers module categories the scaffolder rejects, and runs model-chosen shell commands and file writes without asking.

  • This repository's scaffold-canary still tests pnpm 11. With packageManager in the scaffold, its npx pnpm@11.18.0 install would have run 10.18.2; it now passes --pm-on-fail=ignore (the setting pnpm 11 reads) and fails if the version it runs is not 11.18.0.

[1.15.0] — 2026-09-23 ​

Added ​

  • getAccessToken(), authorizationHeader() and resolveAccessToken() in @tv/extension-sdk/context — the supported way for a federated module to authenticate its own HTTP calls. A module has had none: PlatformContext does not cross the federation boundary (the SDK is not in the shell's shared scope, so usePlatformContext() throws in a remote), and the fleet improvised two. Measured across all module frontends on 2026-09-21, 13 read localStorage['access_token'] — a copy the shell's LegacyTokenBridge writes for them, against the shell's own policy of keeping OIDC tokens out of localStorage — and 11 parse oidc-client-ts's private oidc.user:* storage entry, hand-copied between repositories.

    The helper asks the shell (window.__TV_PLATFORM__.getAccessToken(), tv-building-os#203: an in-memory value, always the token as of the last silent renew) and falls back to the oidc.user:* scan only for a shell that predates the accessor — sessionStorage first, skipping an entry whose expires_at has passed. When the shell is present its null is authoritative: a stale storage entry must not paper over a sign-out. It deliberately does not fall back to localStorage['access_token']; a module that adopts it no longer depends on the bridge, which is what lets the bridge be deleted. Framework-free; call it per request and never cache the result.

    Not a secrecy mechanism — a federated module runs in the shell's JavaScript realm with full host trust (ADR 015). It is a stable contract in place of a storage layout.

Fixed ​

  • The Shell docblock init-module generates told authors to "consume PlatformContext from @tv/extension-sdk/react" — which throws inside Building OS — and to "call tv-api endpoints via the shared axios client", which has never existed. It now points at useSelectedBuildingId() and getAccessToken().

  • init-module shares react-router and react-router-dom with the host, so a routed Shell works inside Building OS. The generated src/Shell.tsx told the author to "mount [pages] via react-router inside this component", while the generated vite.config.ts shared only react and react-dom. A remote that bundles its own router gets its own React context for it, so <Routes> could not see the <BrowserRouter> the shell mounted. Measured with the platform's own shell-integration smoke (infrastructure/.github/actions/shell-smoke) on a fresh scaffold whose Shell uses <Routes>: FAIL with Cannot destructure property 'basename' of 'React.useContext(...)' as it is null under the old shared list, PASS with "index route" rendered under the new one. Both packages are shared, like the shell's own list and like tv-module-leases / tv-module-cafm (@tanstack/react-query stays unshared, for the reason 1.14.1 gives — the two comments now sit together in the generated config, one for each call); there is deliberately no requiredVersion, because @originjs/vite-plugin-federation then takes whatever copy the host provides (array-form shared never sets one — read from the plugin's emitted runtime, 1.4.1). The matching dependencies (^7.0.0, the shell's major) are added to the generated package.json, since a shared name absent from it fails the build; a test now asserts every shared name is a declared dependency.

    src/main.tsx wraps <Shell /> in a <BrowserRouter> — the one thing the standalone entry must supply, because in production the host supplies it — and the Shell docblock now says not to mount a second router and that child routes are relative to /<slug>/*.

    The entry now carries two wrappers, not one: the router joins the mock <PlatformProvider> 1.14.0 added, because each stands in for something the host supplies and nothing supplies standalone, and each was added to stop a different runtime throw in pnpm dev (<Routes> with no router; usePlatformContext() with no provider). The router nests outside the provider, which is the order Building OS itself has — one <BrowserRouter> at the application root, a <PlatformProvider> around each federated module where its route mounts it. Neither reads the other today, so the order is not yet load-bearing; it is chosen because the only direction in which one could ever need the other is a platform context derived from the URL, and a router can never need platform context. A test pins the exact nesting so it cannot silently flip.

  • init-module names the federation container camelCase(slug), not tv-module-<slug>. The platform contract (MODULE_FEDERATION.md, "Container naming contract") is that ui.federationName equals the remote's container name, and tv-api defaults that field to camelCase(slug) when the manifest omits it — which every scaffolded manifest does. So each module this command generated was born out of step with its own registry row; tv-module-industrial shipped that way and had to be renamed by hand (tv-module-industrial#1). slugToCamelCase is exported. Existing modules are unaffected: this changes generated output only.

Changed ​

  • permissions.snapshot.json is regenerated against tv-api 2734d4c (2026-09-23). The committed snapshot dated from tv-api e48deb1 (2026-09-06) and lacked eleven actions the registry has gained since: copilot.llm-config:read/write (tv-api#246), the six multimedia.* rows (tv-api#247), and platform.roles:read/write and telemetry.observations:write (tv-api#252). No existing grant changed. This is the file tv-platform’s permission-catalog.yml compares against the pinned tv-api registry; that check had been red on the pointer-sync pull request since the registry moved.

[1.14.1] — 2026-09-19 ​

Fixed ​

  • A freshly scaffolded module builds again. init-module output from 1.14.0 could not be built at all, in either mode: pnpm install && pnpm run build failed with "useQuery" is not exported by "__vite-optional-peer-dep:@tanstack/react-query:@tv/extension-sdk" and emitted no dist/assets/remoteEntry.js, so the very first thing an invited external developer does with this SDK ended in a red build. The chain is short and entirely inside our own output: 1.14.0's src/main.tsx imports PlatformProvider from @tv/extension-sdk/react; that barrel is @stable and also exports <LocationPicker>, whose import { useQuery } from '@tanstack/react-query' names an optional peer of the SDK; vite build bundles index.html → main.tsx, and rollup resolves every import of a barrel it pulls in long before it tree-shakes. The generated package.json now declares @tanstack/react-query: ^5.100.0.

    The dependency, not the barrel. Splitting LocationPicker out of @tv/extension-sdk/react would remove a documented @stable export from a @stable subpath (STABILITY.md), which a patch release may not do; pointing main.tsx at a narrower subpath would leave the same trap armed for the first author who imports the barrel themselves. Declaring the peer costs nothing measurable: with nothing in the generated tree rendering <LocationPicker>, react-query is tree-shaken out of all nine emitted chunks (grepped for QueryClientProvider in each; zero hits).

    The shared map is unchanged, and says why. @tanstack/react-query is in Building OS's own federation shared scope, and the modules that use it (tv-module-cafm, tv-module-checklists, …) share it; the scaffold does not, because nothing it generates uses it and an unshared package that contributes no bytes cannot desynchronise anything. The generated vite.config.ts now carries that reasoning beside the map, together with the rule it implies: render <LocationPicker> — or call useQuery yourself — and you must add the entry, because React context does not cross the remote boundary for an unshared package, so the host's QueryClientProvider would never reach it.

  • scaffold-canary now builds what it scaffolds, in both modes. The job carried an explicit exemption — "Deliberately NOT covered: a full vite build of the scaffold (it emits no tsconfig.json or index.html, by design)" — whose premise 1.14.0 had already falsified, since the scaffold emits index.html, tsconfig.json and src/main.tsx. Its only compile was tsc --noEmit … src/Shell.tsx, which cannot see this class of defect twice over: it starts at Shell.tsx and never reaches main.tsx, and it is a type check rather than a bundle, so the module graph rollup chokes on is never walked. The job now runs the generated project's own build script against the tree the scaffold declares — nothing installed for the occasion — and fails unless dist/assets/remoteEntry.js exists, because a federation config that quietly stops emitting a remote entry leaves a green vite build and an unloadable module. A mode: [default, external] matrix covers both scaffold shapes; today they have the same buildable surface, which is exactly why a regression in one would otherwise be assumed absent from the other. On runs with no registry token (fork PRs, Dependabot) every @tv/* dependency is stripped, so the step declares itself unavailable with a ::warning:: rather than asserting something weaker. A unit test asserts the declaration in milliseconds on every pull request, so the cheap half fails first.

[1.14.0] — 2026-09-19 ​

Added ​

  • A scaffolded module can be served to a shell: init-module emits the build-watch/preview pair, a pinned remote port, and a README that names the URL. The generated vite.config.ts carried a server block and nothing else, so the author's natural first move — pnpm dev — served the app on 5001 and emitted no dist/assets/remoteEntry.js at all: @originjs/vite-plugin-federation produces a remote only in a build, and a shell pointed at the dev server gets a 404 the browser reports as a CORS failure. The config now also carries preview: { port, strictPort: true, cors: true }, and package.json two scripts — dev:remote (vite build --watch) and serve:remote (vite preview --port <p> --strictPort) — run in two terminals. No process runner (concurrently, npm-run-all) is added: a second terminal is a smaller cost than a dependency in every scaffold. The port is previewPortFor(devPort), the dev port plus 1000 (default 6001), so a module that moved its dev port moves its remote with it; strictPort because vite would otherwise hop to the next free port and move the remote out from under the registry row that points at it. scaffoldReadme() is new and README.md is now part of the scaffolded tree — it was listed as "nice-to-have" in the file set and emitted for nobody — and it names the exact URL (http://localhost:6001/assets/remoteEntry.js), because a URL derived from three defaults across two files is a URL nobody derives.

  • tv-sdk sandbox install <manifest> (experimental) puts a module into your own sandbox and prints the URL to open. Two calls against the sandbox's own tv-api, in order — POST /api/v1/registry/modules/ingest with { manifest, remoteEntryUrl }, then POST /api/v1/licenses on the seeded building — because the shell renders from licence state, so a registered-but-unlicensed module is simply absent. --remote-entry is what makes it useful: it sets the registry row's remoteEntryUrl, so the shell loads the module from the developer's own machine (http://localhost:6001/assets/remoteEntry.js), and moving where you serve from is a re-run of this command rather than a shell rebuild. --sandbox=<id> resolves the tv-api URL, the token, the seeded building and the console URL in one lookup; without it the TV_API_URL / TV_API_TOKEN pair sandbox connect prints is used and the building is looked up from the sandbox. The printed URL is <sandbox console URL>/sandbox/<slug> — the sandbox shell mounts one generic /sandbox/:slug route driven by the registry row's ui.remoteEntryUrl, ui.federationName and ui.exposeName (tv-building-os#196), never the production shell's /<slug>. federationName is sent only when the manifest names one, because tv-api defaults it to camelCase(slug) and a value guessed here would disagree with the container the module builds. Known limitation, stated in the failure message rather than discovered: the registry-ingest and licence endpoints still require a platform-admin token, which a sandbox owner does not have, so a 401/403 is the expected answer today — it prints a sentence saying so, not a stack trace, and stops before the licence call. An existing licence (409) is treated as success, so the command is repeatable.

  • tv-sdk sandbox create --type=university. The type was rejected by this parser alone: @tv/sandbox-seed has carried a university template since the university demo stand (its BuildingType enum is exactly mall | office | university), and the provisioner and the docs take it, so a developer who asked for one got Invalid --type and no way forward. The rejection message now names all three, and nothing else in the family changed.

  • init-module --external (experimental) scaffolds a module an author outside the organisation can actually build. Today's output does not merely degrade outside tangovision — it cannot run: the generated .github/workflows/ci.yml asks for runs-on: self-hosted, which on a repo with no self-hosted runner queues until GitHub cancels it at 24 h (a CANCELLED check with no logs, no steps and an empty runner_name), and calls three composite actions out of the private tangovision/infrastructure, each of which fails the job at start with "repository not found"; .github/dependabot.yml authenticates to the private registry with NPM_READ_TOKEN, an organisation-level Dependabot secret that resolves to an empty string elsewhere so every npm run fails on auth; and AGENTS.md carries the org platform-rules block, most of which describes machinery an outsider cannot reach. With the flag: CI on ubuntu-latest with pnpm/action-setup@v4 pinned to 10.18.2 (the platform constant, written out because the action holding it is unreachable — with a comment saying so) and the developer's own TV_NPM_TOKEN repository secret, masked with ::add-mask:: and written to an npmrc under $RUNNER_TEMP via NPM_CONFIG_USERCONFIG, never ~/.npmrc, then install, tv-sdk validate, typecheck, tests, a real federation build and tv-sdk check-exposes; a Dependabot config with no registry block, keeping the fleet's held majors (they are migrations, not org policy) and carrying the recipe for restoring the block with the developer's own secret; and an AGENTS.md without the platform-rules block — omitted entirely rather than trimmed, because CI compares that block byte-for-byte against tangovision/templates/PLATFORM-RULES.md — carrying instead the rules that bind a module wherever it is built: the pnpm pin, never commit the registry token, both-or-neither i18n sharing, the use-sync-external-store shim, and the fact that a sandbox shell is the only place an external author can observe the ADR 103 failure class. Default output is unchanged byte for byte: measured by generating a module from origin/main and from this branch and diffing the trees (the only differences are the two entries above), and asserted in a test that also pins the external flag to exactly three changed files.

  • The generated src/main.tsx wraps the standalone entry in a mock platform context. The SDK docs advertise createMockPlatformContext() from @tv/extension-sdk/testing as how you develop a module outside the shell, and the scaffold emitted a bare createRoot(<Shell/>) that used none of it — so an author who reached for usePlatformContext() met a thrown error in pnpm dev with nothing pointing at the cause. The entry now renders <PlatformProvider value={createMockPlatformContext()}>, with an override example inline. It wraps only this entry, and Shell.tsx still takes no provider: React context does not cross the federation boundary here, because @tv/extension-sdk/react is not in the shared map — the remote bundles its own copy with its own createContext() object, so the provider Building OS mounts populates a different context than the one the remote reads and usePlatformContext() throws inside a remote however correctly the shell behaved. The generated Shell therefore stays on hooks that work in both places (useSelectedBuildingId(), which reads the host's published selection through storage and a window event rather than through context).

Changed ​

  • The platform-rules block that init-module writes into AGENTS.md gains one rule: every change reaches the default branch through a pull request, never a git push onto main/master, admin or not, and a gitlink SHA is resolved from the remote, never typed or expanded from a 7-character abbreviation. PLATFORM_RULES in src/manifest/scaffold.ts is re-synced verbatim from tangovision/templates/PLATFORM-RULES.md, which the platform-rules CI job compares on every run. A module scaffolded by an already-published SDK still gets the old block until the next release; the reasoning lives in §3.4 of the company-wide AGENTS.md.

Added ​

  • validateManifest reports review flags; tv-sdk validate prints them. A successful result gains reviewFlags: ReviewFlag[]. The first flag, reserved_subject_request, marks a manifest outside the @tv/ scope that requests a subject under a kernel-domain prefix (building., platform., telemetry., cafm., compliance., sustainability.). tv-sdk validate lists them under the success line and still exits 0; --format=json carries them as reviewFlags. Additive: ok, manifest and issues are unchanged.

    A flag, deliberately not a rejection. PermissionCatalog.reservedPrefixes has said since May that "manifest validators reject partner modules requesting subjects under these prefixes" — and nothing ever read the field. Enforcing that sentence literally would have been wrong: requesting kernel subjects is how a module reads a building at all (16 of the 36 first-party manifests did on 2026-09-18, 41 requests), so a partner module could have done nothing useful. ADR 015's trust model is a human review in which "permissions are justified"; reviewFlags is the list that reviewer reads, and a flagged request with no reason says so.

    Not a security boundary. The scope comes from the manifest's own id, which its author writes; a partner who types @tv/… gets no flags. Knowing who is actually submitting belongs to the registry (Phase 4). mcpTools[].permissions are not considered: those strings gate the user invoking a tool and grant the module nothing.

    The prefix list moved to a dependency-free permissions/reserved.ts (RESERVED_SUBJECT_PREFIXES, FIRST_PARTY_SCOPE, both exported from @tv/extension-sdk/permissions) so the manifest validator can import it without pulling node:fs in through extract.ts; a test pins the catalog's reservedPrefixes to the same list.

[1.13.0] — 2026-09-11 ​

Added ​

  • init-module now scaffolds CHANGELOG.md. The AGENTS.md this same command writes told the author twice to maintain a file it never created — once in the platform-rules block ("every deployable has a CHANGELOG.md (Keep a Changelog, ## [Unreleased])") and once in the module section ("Update CHANGELOG.md under ## [Unreleased] with every feature change") — so the first change to a scaffolded module had nowhere to record itself. Measured 2026-09-10 across all 41 live tv-module-* repositories: exactly four lacked a changelog (tv-module-chat, tv-module-process-builder, tv-module-surveys, tv-module-training), all four generated by this command, all four carrying that instruction verbatim. It surfaced only when a fleet security sweep went to add an entry and found no file to add it to. The new scaffoldChangelog() in src/manifest/scaffold.ts emits the Keep a Changelog header and an empty ## [Unreleased]: seeding it with an "initial scaffold" entry would record the generator's activity rather than the module's, and leave the first real entry under a heading that already looks used. The comment block carries the one rule the Keep a Changelog spec does not — ### Sales Impact is customer-facing copy, and an omitted section beats an engineering-internal one (AGENTS.md §3.3). The four repositories above are backfilled with the byte-identical file in companion pull requests.

  • init-module now scaffolds .github/dependabot.yml. Every module this command generated was born with Dependabot alerts (no config needed) but no version-update PR: an unconfigured npm updater resolves @tv/* anonymously against the private Verdaccio, gets 401, and the whole job aborts silently — the module had zero Dependabot coverage from the first commit and nothing surfaced that until an advisory sat open with no PR against it. The new scaffoldDependabotYml() in src/manifest/scaffold.ts emits the same registries: { tv-verdaccio } block, NPM_READ_TOKEN-from-org-secret wiring, and open-pull-requests-limit: 0 (security updates only, by default) already applied repo-by-repo to 13 fleet repos — including two modules this same command generated (tv-module-catalog, tv-module-mindmap) — that had the identical gap. tangovision/templates gets the matching fix for its own scaffold roots (backend-nestjs, frontend-nextjs, frontend-react, frontend-shell, package-contracts, package-ui) in a companion PR.

Changed ​

  • init-module's .github/dependabot.yml now carries the fleet's held majors, and leaves routine version updates on. The generated config had the Verdaccio registry block (from the previous entry) but no ignore: list and open-pull-requests-limit: 0, so a new module was born tracking every major the fleet holds deliberately — and, because version updates were off, bumping nothing at all until somebody remembered to raise the limit by hand. An ignore list on a config that raises no version pull request shapes nothing; the limit was the same "born with no coverage" gap the registry block was added to close. scaffoldDependabotYml() in src/manifest/scaffold.ts now emits the fleet reference shape (tangovision/tv-module-leases/.github/dependabot.yml): updates on at limit 10 with a minor-and-patch group and a dependencies label, and majors held for react-i18next (the 2026-08-27 incident — the 15 → 17 bump crashed every federated remote inside the building-os shell, and standalone module CI cannot see that failure class), typescript (7 is the native Go compiler, a compiler swap rather than a bump), @types/node (types ahead of the base image typecheck against APIs that do not exist at run time), prisma / @prisma/client (Prisma 7 removes datasource.url from schema.prisma, P1012 — a schema migration, and until it is done every prisma 7 PR fails prisma generate), and @nestjs/* (Nest 12 ships ESM-only packages, so the fleet's CommonJS Jest setup fails app.module.spec.ts with "Must use import to load ES Module"; measured on Node 26 with jest-runtime 30.5, so a newer Node is not the fix). Each hold carries its reason inline, so a module author can tell a live hold from a stale one before lifting it.

    Adapted to what this command actually emits rather than copied wholesale: one flat root (directory: "/", not leases' /backend + /frontend pair) and no docker ecosystem, because the scaffold ships no Dockerfile — when it gains one it will build FROM public.ecr.aws like the rest of the fleet, where Dependabot's proxy is refused (DENIED, then HTTP 429) and the job fails every run. A github-actions entry ignores tangovision/infrastructure*: the generated ci.yml calls three composite actions out of that private repo, which Dependabot's repo-scoped token cannot read, so every run would otherwise report git_dependencies_not_reachable on pins that move when we move them. Four of the holds (@types/node, prisma, @prisma/client, @nestjs/*) name dependencies a freshly scaffolded frontend does not have yet; they are inert until the module grows the backend/ the generated CI already documents, and carrying them from the first commit is what stops each new repo rediscovering these incidents on its own.

    tangovision/templates is kept in step in a companion pull request — templates#67 had already held @nestjs/* in that repo's own root config and in the backend-nestjs scaffold, the only two places @nestjs/* appears there; the companion extends the rest of the list to all six scaffold roots, which is where a repo generated from a template inherits its coverage.

Fixed ​

  • publishConfig.registry pointed at public npmjs, where @tv/* has never been published and the scope isn't even ours (curl https://registry.npmjs.org/@tv%2fextension-sdk → 404; the @tv org page → 403). Every real publish has always gone to the private Verdaccio (https://npm.k8s.tangovision.dev/) — manual publishes because the @tv:registry scope mapping in ~/.npmrc overrides both publishConfig and an explicit --registry flag for scoped packages (verified during the 1.11.0 release: npm publish --registry=https://registry.npmjs.org/ still PUT to Verdaccio), and CI publishes because publish-extension-sdk.yml in tv-platform gets its registry from actions/setup-node's registry-url + scope: '@tv' inputs, which write the same kind of scope-specific mapping — not from publishConfig. So the stale value was never actually reachable, only misleading: a reader (or an agent) who trusted it would conclude the package is public, and an environment without the scope mapping would have sent a real publish attempt at a registry where we don't control the scope. publishConfig.registry now reads https://npm.k8s.tangovision.dev/, matching every other reference to the registry in this repo (README, AGENTS.md). Checked and unaffected: the generated .npmrc template (scaffoldNpmrc() in src/manifest/scaffold.ts) intentionally keeps a bare registry=https://registry.npmjs.org/ alongside the @tv:registry= override — that's the correct shape (unscoped deps from public npm, @tv/* from Verdaccio), not the same bug. No other publishConfig block exists in this repository.

Sales Impact ​

  • Internal correctness fix to a package manifest field; no customer-facing behavior changed. Omitted per AGENTS.md §3.3.
  • Internal scaffolding fix (a new module is born with the changelog its own AGENTS.md tells the author to maintain); no customer-facing behavior changed. Omitted per AGENTS.md §3.3.
  • Internal scaffolding fix (new modules now get Dependabot security updates from their first commit); no customer-facing behavior changed. Omitted per AGENTS.md §3.3.
  • Internal scaffolding fix (new modules are born holding the majors the fleet holds, and receiving the routine updates they should); no customer-facing behavior changed. Omitted per AGENTS.md §3.3.

1.12.0 ​

Added ​

  • OccupantScopeService / isOccupantOnly / OccupantScope in @tv/extension-sdk/nestjs (@experimental). The occupant-mode adapter (ADR 106, decision 3): classify a verified caller as occupant-only (ADR 097's tenant archetype with no staff archetype), then resolve their tenancy - tenant, building, live-lease space ids, role - from the leases module's GET /api/tenant/scope with the caller's own forwarded token. Fails closed: unreachable leases is 503, an unlinked account is 403, never an unscoped read. Extracted from the two identical copies in tv-module-service-desk and tv-module-accounts-receivable, which switch to this export next - the drift-by-copy problem ADR 106's notes flagged the day the second copy was made.

[1.11.0] — 2026-08-29 ​

Changed ​

  • sandbox create now polls the provisioner instead of holding one HTTP request open for the whole provision. The provisioner answers POST /api/v1/sandboxes with 202 Accepted and a record in provisioning status (tv-platform#658); the CLI polls GET /api/v1/sandboxes/:id every 5s (progress line every ~30s in human mode, silent in --format=json) until the status turns ready or failed, giving up with exit 2 after 15 minutes — provisioning continues server-side and sandbox list / sandbox connect <id> pick it up later. Old synchronous provisioners still work: a ready response skips the polling loop, and when the create call is cut off mid-flight (the pre-202 failure mode — the ingress used to 504 a create that then succeeded server-side) the CLI reconciles by display name via GET /api/v1/sandboxes and resumes watching the sandbox it finds. New exports for tooling that drives creates itself: waitForSandboxTerminal, findLatestSandboxByName, SandboxWaitTimeoutError.

Sales Impact ​

  • Persona: external module developer on a slow or proxied network.
  • Problem Solved: sandbox create no longer reports a scary error for a create that actually succeeded — the command now reliably ends with the sandbox's real outcome, however long seeding takes.
  • Pitch Hook: kick off a sandbox and watch it come up — the CLI tracks provisioning to completion instead of gambling on one long HTTP request.

[1.10.0] — 2026-08-29 ​

Added ​

  • login / logout / whoami — the interactive credential path (Phase 3.5 of the Module Developer Platform initiative). npx @tv/extension-sdk login completes the OAuth 2.0 Device Authorization Grant (RFC 8628) against the developers Keycloak realm: it opens the browser to the verification page (printing the URL and user code as the fallback — --no-browser skips the launch), polls the token endpoint with the RFC's pacing rules, and stores the resulting tokens in the OS keyring via @napi-rs/keyring — macOS Keychain, Linux Secret Service, Windows Credential Manager. Where no keyring is usable (headless Linux without a D-Bus secret service, unsupported platforms) the tokens fall back to ~/.config/tv-sdk/credentials.json with 0600 permissions and a stderr warning; TV_SDK_TOKEN_STORE=file|keyring forces the backend — file is the right setting for CI, keyring turns a silent fallback into a hard error. The flow requests offline_access deliberately: without it the refresh token dies with the realm's 30-minute SSO idle timeout, with it the session survives until 30 days unused (the realm's offline idle limit). logout revokes the refresh token server-side (RFC 7009) and clears local storage even when the realm is unreachable; whoami proves the whole chain by round-tripping storage → refresh → userinfo. Tokens are never printed. The Keycloak side is a dedicated public client tv-sdk-cli with ONLY the device grant enabled (standard flow and direct grants off) and the same developer-api audience mapper as developer-portal, so provisioner-side JWT verification needed no changes. Limits worth knowing: login is the interactive path — tvk_… API keys minted in the developer portal remain the non-interactive credential for CI, and the account must exist in the invite-only developers realm first.

  • sandbox live subcommands now actually call the deployed provisioner. list, connect, extend, delete, reset, and live create were "service is not yet deployed" stubs from Phase 1a.4; they now call https://sandbox-api.k8s.tangovision.dev (override with --api, TV_SANDBOX_API_URL, or TV_API_URL, in that order — the localhost default is gone now that the service is real). Credential resolution, in order: --token flag, TV_API_TOKEN env, then the stored login session with automatic token refresh — so a logged-in developer runs npx @tv/extension-sdk sandbox create … with no env setup at all, while existing API-key workflows keep working unchanged. New --ttl-days=N flag on create (1–30, service default 14). Distinct failure modes are distinguished: 401 tells you which credential path was rejected and what to do, quota refusals surface the provisioner's structured message, and an unreachable service still exits 2 where a refused request exits 1.

  • init-module scaffolds the ADR 103 federation-safe i18n setup, and the module it emits now builds. Three things every generated frontend is born with, ported from the canonical tv-module-example: src/shims/use-sync-external-store-shim.ts (an import-then-re-export of useSyncExternalStore from react — a plain export { x } from 'react' re-export is NOT rewritten by the federation plugin and reintroduces the second-React crash), the matching resolve.alias for use-sync-external-store/shim in the generated vite.config.ts, and a generated .github/workflows/ci.yml whose frontend checks job builds the real federation remote and mounts it in the shell-integration smoke (tangovision/infrastructure/.github/actions/shell-smoke@master, report-only). Without the alias, every scaffold carrying react-i18next ≥ 16 shipped the 2026-08-27 incident by construction; the smoke is the only CI signal that can see that failure class.

    For that CI to be green on the first push, the scaffold also gains the files a generated module was missing to build at all: tsconfig.json (the generated tsc -b && vite build / tsc --noEmit scripts had nothing to run against), index.html + src/main.tsx (the standalone dev entry vite build needs), .npmrc (without it the first pnpm install resolves @tv/extension-sdk against npmjs and 404s), and src/locales/catalogues.test.ts — the key-parity test the generated src/i18n.ts docblock was already promising ("the parity check in CI will hold you to it") without shipping it. New exports: scaffoldShim(), scaffoldCiWorkflow(), scaffoldTsconfig(), scaffoldIndexHtml(), scaffoldMainEntry(), scaffoldNpmrc(), scaffoldCatalogueTest().

  • init-module emits a .gitignore. A freshly scaffolded module had none, so its very first commit could take node_modules/, dist/ and coverage output with it — and because ignoring an already-tracked path does not untrack it (platform rule 3.1), that mistake is permanent until someone runs git rm --cached, which is exactly the cleanup the 2026-08 fleet sweep spent on repos born this way. The generated rules cover install output, build output (dist/, *.tsbuildinfo), coverage, logs and local .env files, and deliberately do NOT ignore pnpm-lock.yaml: the generated CI installs with --frozen-lockfile, which fails on a repo without a committed lockfile. Exported as scaffoldGitignore(). (Extracted from the still-useful part of #82; that PR's i18n half predates ADR 103 and was superseded by the shim + alias work above.)

Changed ​

  • The shipped platform rules state that pnpm.overrides replaces, rather than merges. Re-synced PLATFORM_RULES from tangovision/templates/PLATFORM-RULES.md (templates#32). The override-floor bullet previously stopped at "pnpm-workspace.yaml, not package.json", which is correct advice and still misses the way the floors actually came off: a non-empty pnpm.overrides in an install root's package.json replaces that root's pnpm-workspace.yaml overrides: block wholesale, not key by key, so a floor written in only one of the two files is not a floor at all. Both files may legitimately carry the block — pnpm install --ignore-workspace does not read pnpm-workspace.yaml — but then they must be kept byte-identical, and ERR_PNPM_LOCKFILE_CONFIG_MISMATCH must never be answered with --no-frozen-lockfile, which turns the loud failure green and the silent downgrade permanent. tv-platform#598 pasted a generated floor list into 13 package.json files and made six workspace blocks inert this way; in tv-module-notifications a deepmerge-ts CVE floor silently weakened from ^8.0.1 to >=7.1.5 (GHSA-ggr8-5vv4-36mx) while the comment above it still claimed the two files were in sync (tv-platform#615). Full rationale and the override-source.yml gate that detects it: tangovision/infrastructure/docs/ci-gates.md#the-pnpm-override-source-invariant (infrastructure#414).

  • Scaffolded modules pin react-i18next at ^17.0.0 (was ^16.0.0) — the major ADR 103 verified as safe in both sanctioned i18n models and what tv-module-example runs. Safe only together with the shim alias above; neither half may be dropped without the other.

  • buildingTypes documents where its vocabulary is decided. The field's slugs are validated at ingest, against tv-api's building_types registry — the platform's single source of truth for building types (ADR 097 layer 2, seeded in tv-api#162). POST /api/v1/registry/modules/ingest now rejects a manifest naming a type that does not exist, listing the valid values; before, a typo ingested happily and gated the module to nothing, silently and permanently.

    The Zod schema deliberately does not enumerate the slugs. This package ships on its own release cadence, so an enum here would be a fourth copy of a list that already disagreed with itself in three places, and a module could not adopt a newly added building type until the SDK caught up. The schema keeps validating slug shape; the registry decides slug membership.

Security ​

  • The copilot's init_module tool and the RU contracts doc invoke the CLI by its full package name. Both used bare npx tv-sdk — the doc as an instruction, the copilot as a string passed to execSync. The package name tv-sdk is unclaimed on public npm (npm view tv-sdk → 404), and outside a directory with @tv/extension-sdk installed locally, npx resolves a bare name against registry.npmjs.org — so a squatter publishing tv-sdk would have had their code executed by every developer following the checklist, and by the copilot itself when run outside such a directory. Both sites now spell npx @tv/extension-sdk <cmd>, which resolves through the authenticated @tv registry wherever it runs; since the package declares a single bin, the two forms are otherwise identical. The developer-docs guides carried the same instruction in 34 places (fixed in tv-platform#647), and infrastructure/docs/ci-gates.md in one.

Sales Impact ​

  • Persona: external module developers and ISV partners in the developer-platform preview.
  • Problem Solved: getting a credential into the CLI used to mean signing in to the portal, minting an API key, and pasting it into environment variables before the first sandbox command could run — and that key then lived in shell history or a dotfile. Now it is one browser sign-in: npx @tv/extension-sdk login, and every sandbox command authenticates itself from the OS keyring.
  • Pitch Hook: "Sign in once in your browser and the SDK remembers you — securely, in your OS keyring, for up to 30 days of inactivity. No tokens in dotfiles, and logout revokes the session server-side, not just locally." (Scope of the claim: the interactive CLI path in the invite-only preview; CI and other non-interactive callers still use portal-minted API keys.)

[1.9.0] — 2026-08-25 ​

Added ​

  • init-module emits AGENTS.md and CLAUDE.md. The AGENTS.md opens with the platform rules from tangovision/templates/PLATFORM-RULES.md (English only, call build-push.yml, setup-pnpm / setup-tv-registry, org secrets, override floors, shared-runner constraints), so an agent working in a scaffolded module repo can see rules that otherwise live only in the ceo monorepo. Exported as scaffoldAgentsMd(), scaffoldClaudeMd() and the PLATFORM_RULES constant.

[1.8.0] — 2026-08-20 ​

Added ​

  • building.fault.created joins the event catalog. tv-api's FDD engine has emitted this event since the fault/work-order bridge landed — src/fdd/fdd.service.ts publishes it immediately after building.alarm.triggered whenever a diagnostic rule opens a fault — but it was never declared in EVENT_CATALOG, so it was absent from tv-events.snapshot.json and invisible to every piece of tooling built on that snapshot.

    The visible symptom was in check-pact: @tv/module-cafm declares a full payloadContract on it (Auto-create a work order from an FDD fault) and was told EVENT_NOT_PUBLISHED — rule 1, the diagnosis reserved for a consumer subscribing to an event nothing emits. That was wrong on the facts. The producer exists, is wired end to end (EventBusService.publish routes it to tv.building.<buildingId>.building.fault.created, which is exactly the subject CAFM's TicketBridgeService subscribes to), and CAFM's handler, dedup path, faultId column and unit tests are all in place. Only the declaration was missing. tv-platform's manifest-fleet-gate carried the gap on its allowlist; that line comes off with this release.

    The payload is transcribed from the emit site rather than from the consumer's expectations: faultId, buildingId, severity, title and detectedAt are required (all non-null in the faults table, and detectedAt defaults to now()), while elementId and pointId are optional (both nullable columns, emitted as ?? undefined).

    severity is deliberately an open z.string().min(1) and not an enum like building.alarm.triggered's. The faults.severity column is a free String written by rule definitions, and tv-api's own summary code guards with if (f.severity in bySeverity) instead of assuming the set — so an enum here would advertise a guarantee the producer does not make, and would hand consumers a false PRODUCER_EMITS_UNHANDLED_ENUM signal. Common values are critical/high/medium/low; CAFM's severity mapping already falls back for anything else.

[1.7.0] — 2026-08-20 ​

Added ​

  • init-module emits a pnpm-workspace.yaml with the fleet's build-script approval lists. A freshly scaffolded module was born unable to run the very pnpm install the CLI prints as its next step: pnpm 11 hard-fails install with ERR_PNPM_IGNORED_BUILDS on any dependency whose build script is not approved (pnpm 10, the platform CI pin, only warns), and the scaffold's own tree contains one such package — esbuild, via vite. This is the same failure that had to be fixed one repo at a time across all eleven existing tv-module-* repositories (e.g. tv-module-cafm#79, tv-module-leases#89/#90).

    The generated file carries the approvals in the fleet's dual-list convention — allowBuilds (a map, read by pnpm 11) mirrored by onlyBuiltDependencies (a list, read by pnpm 10), alphabetical — both rendered from a single exported constant, APPROVED_BUILD_PACKAGES, so the generated lists cannot drift apart the way hand-mirrored ones have. The set is the platform's canonical one (what tv-module-cafm and tv-module-example carry), not the scaffold's minimal need: beyond esbuild it pre-approves what the backend every module grows next actually pulls in — @prisma/engines (prisma postinstall) and unrs-resolver (jest ≥ 30.4's native resolver) above all. @scarf/scarf is deliberately not listed: the scaffold has no @nestjs/swagger, and the file's comment instructs the author to add the fleet's explicit block ('@scarf/scarf': false + ignoredBuiltDependencies) rather than an approval if swagger ever brings it in.

    The scaffold-canary workflow gains an install of the generated module under a pinned pnpm 11.18.0. The existing pnpm 10 install cannot regress this guarantee — 10 only warns — so without it the failure would quietly return the first time the scaffold's dependency tree grows a new build-script package.

[1.6.1] — 2026-08-20 ​

Fixed ​

  • tv-sdk check-pact resolves its default snapshot without assuming cwd. The default producer snapshot was a single cwd-relative path, packages/tv-extension-sdk/tv-events.snapshot.json, which only exists when the command runs from the tv-platform meta-repo root. Running check-pact from inside the SDK repo — where tv-events.snapshot.json sits in the repo root — failed with ENOENT, and the standing workaround was to pass --snapshot=tv-events.snapshot.json by hand. Pre-existing; 1.6.0 left it alone to keep the --producer change scoped.

    The default is now a search over candidates, most-authoritative first: the meta-repo layout relative to cwd (unchanged, still the common case in CI), then the snapshot bundled with the running SDK install, then an upward walk from cwd checking both the bare filename and the meta-repo layout at each ancestor. The bundled candidate is resolved from the module's own URL rather than from cwd — cwd is the thing that cannot be trusted here — which is what makes the SDK repo, an npx @tv/extension-sdk install, and vitest running the TypeScript source all land on the right file. tv-events.snapshot.json is listed in package.json files, so it is present at that root in a published tarball too.

    An explicit --snapshot=<path> still wins outright and is still what the error tells you to reach for, but the failure now lists every candidate that was tried instead of naming one path that was never going to exist.

    check-events and snapshot-events were checked for the same defect and do not share it: both take a required positional path, and snapshot-events' path is a write target, where an upward search would be wrong. They are unchanged.

    Resolution moved out of cli.ts into src/events/snapshotPath.ts (findDefaultSnapshotPath(), defaultSnapshotCandidates()) so it is unit-testable — cli.ts runs process.exit() on import. It is deliberately not re-exported from @tv/extension-sdk/events, which module frontends import and which must stay bundler-safe; this file reaches for node:fs.

[1.6.0] — 2026-08-20 ​

Added ​

  • Modules can act as event producers in check-pact. The pact checker built its producer view solely from EVENT_CATALOG — tv-api's first-party events. Module-published events are declared in module-manifest.json under events.publishes and never appear in that catalog by design, so a consumer declaring a payloadContract on one tripped rule 1 (EVENT_NOT_PUBLISHED) and failed CI with no correct way out.

    tv-sdk check-pact gains a repeatable --producer=<manifest> flag that federates a producing module's events.publishes into the producer view. The first-party snapshot is merged first, so tv-api keeps ownership if a module ever collides with a catalog event; a second claim on the same event name is reported as a conflict rather than silently overwriting.

    The alternative — grafting module events into EVENT_CATALOG — was rejected: it misattributes them to tv-api, couples a module's contract to the SDK release cycle, and enforces nothing at runtime, since the catalog has no runtime consumer and feeds snapshot/CI tooling only.

    New exports on @tv/extension-sdk (src/events/manifestProducer.ts): buildModuleProducerSnapshot() converts a manifest's events.publishes[*].payloadContract into the snapshot shape buildEventSchemaSnapshot() emits, and mergeProducerSnapshots() unions producer snapshots. Both are pure and take generatedAt from the caller so regenerated output stays byte-identical.

  • payloadContract is now symmetric. On events.subscribes[*] it remains the subset of a producer's payload the consumer reads; on events.publishes[*] it describes what the module guarantees to emit. Same wire format and no schema change — manifest.schema.json and tv-events.snapshot.json are byte-identical to 1.5.0.

    A publishes entry without a payloadContract still registers the event, deliberately: the module does publish it, so a subscriber must never be told EVENT_NOT_PUBLISHED. Such an entry carries an explicit x-tv-opaque marker (OPAQUE_PAYLOAD_MARKER) and surfaces as a new info-level PRODUCER_PAYLOAD_OPAQUE diff, making the coverage gap visible without turning CI red. The marker is explicit rather than inferred from a missing properties key, because that key is also absent on a corrupt or truncated snapshot — which must keep reporting breaking diffs as it always has.

[1.5.0] — 2026-08-19 ​

Added ​

  • Shared <LocationPicker> on @tv/extension-sdk/react. The storey → space → optional element cascade that CAFM and service-desk each hand-rolled over the same three graph endpoints is now one component (ADR 094 phase 5). It runs over any BuildingGraphClient passed as a prop, queries through buildingGraphKeys with the same query shapes as the modules' useBuildingGraph hooks (so both observe the same react-query cache entries), and emits ids only — {}, {storeyId}, {storeyId, spaceId} or all three, spread-safe into create payloads. The denormalized locationName label is a write-once historical value by decision; modules that persist one compose it at submit time from data the picker already fetched.

    What each donor contributed: CAFM's client-side space search above a threshold (spaceSearchThreshold, default 8) and its element formatting hook (formatElement); service-desk's graceful error branch, ids-only emission semantics and data-testid contract (location-picker, -no-building, -unavailable, plus new per-select ids).

    Integration stays on the module's side of the boundary, which is why the three new peer dependencies are optional (peerDependenciesMeta): @tanstack/react-query is imported only by this component; @tv/ui is never imported at all — it resolves only through the authenticated registry and this repo's CI installs without credentials, so the picker takes Input/Label through a structurally-typed components slot and renders native elements otherwise; react-i18next stays entirely in the host — every user-facing string arrives via the required labels prop, no namespace is baked in.

[1.4.1] — 2026-08-19 ​

Changed ​

  • Vendored graph types re-synced with upstream. tv-building-graph#1 aligned that package with schema.prisma — nullable BuildingChangeset.buildingId, the new siteId, and optional provenance columns (externalId, sourceSystem, classifications) on Storey/Space/Element — landing upstream what this SDK's wire layer had been declaring on its own since 1.4.0. The vendored copy now matches upstream main byte-for-byte below its provenance header, and the wire layer derives instead of overriding: the buildingId/siteId overrides on Changeset are gone, and ProvenanceFields is Required<Pick<…>> of the vendored Storey rather than a hand-maintained parallel declaration. Provenance stays non-optional on the wire — upstream made the columns optional for construction-side ergonomics, but API responses always include them, and the SDK keeps that guarantee. No public type changes: every exported wire shape is structurally identical to 1.4.0.

[1.4.0] — 2026-08-19 ​

Added ​

  • Typed building-graph client. New subpath @tv/extension-sdk/graph exports createBuildingGraphClient(): read + write for the core graph (storeys, spaces, elements) and its versioning surface (changesets with the full draft → submit → review → apply → revert lifecycle, point-in-time history and diffs, named snapshots), plus both catalogs. Every path, query parameter, request body and response shape mirrors tv-api's controllers and DTOs, so a consumer that typechecks is calling endpoints that exist — this is ADR 094 phase 2.

    The wire types derive from the @tv/building-graph package (vendored, the way the events catalog and the OpenAPI/permissions snapshots already ship in this package — @tv/* resolves only through the authenticated registry, and this repo's CI must keep installing without credentials) through a Serialized<T> transform: over JSON, every Date is an ISO-8601 string. Response-only realities the package does not model — nested includes, pagination envelopes, provenance columns (externalId, sourceSystem, classifications), the nullable buildingId + siteId on site-level changesets — are layered on top, verified against schema.prisma.

    Why this exists: the fleet survey behind ADR 094 counted six hand-rolled Space types and two copy-pasted useBuildingGraph.ts hooks (service-desk, CAFM) drifting against the same six endpoints, and the shell's Structure page added a third client the same week. One typed client, three deletions.

    The client is transport-agnostic — it runs over any PlatformApiClient (the context-provided client inside a federated module, or the shell's own). For the axios instances every current consumer actually has, platformApiFromAxios() wraps one in five lines, and apiPrefix: '' accommodates base URLs that already end in /api/v1. Also exported: buildingGraphKeys, a canonical react-query key factory (plain arrays, no react-query dependency) whose root segment matches the module hooks it replaces, so their caches survive the migration.

[1.3.1] — 2026-08-18 ​

Fixed ​

  • Every subpath is now loadable from CommonJS. The exports map declared only import and types on eleven of its twelve subpaths — ., ./manifest, ./context, ./events, ./api, ./heartbeat, ./react, ./testing, ./version-check, ./permissions and ./i18n. Only ./nestjs declared require. Each of the eleven now declares it too, resolving to the same file as import.

    This failed in the one way that survives every gate. TypeScript resolves a CommonJS import of ./i18n by walking the conditions, finding no require, and matching types instead — so tsc reports success and the image builds clean. Node has no concept of a types condition, finds nothing it can load, and throws ERR_PACKAGE_PATH_NOT_EXPORTED the first time the code actually runs. Compile-time green, runtime dead.

    It matters now because of who consumes this package. All twenty-five module backends are CommonJS — nineteen on module: commonjs, six on nodenext with no "type": "module" — so every one of them resolves this package under the require condition. None imports ./i18n yet, which is the only reason 1.3.0 shipped without anyone noticing; the i18n migration currently moving through the fleet is what would have found it, one module at a time, in whatever environment ran the code first.

    The package stays "type": "module" and ships one build: require and import point at the same ESM file, which Node loads through require(esm). That is what ./nestjs has always done, and the module images run Node 22, where it is unflagged. Nothing about the ESM entry points changes, so bundled frontend consumers resolving under import are unaffected.

    A contract test now asserts that every subpath declares require and types, and that require and import agree — the check that would have caught this when ./i18n was added in 1.3.0.

[1.3.0] — 2026-08-09 ​

Added ​

  • Locale resolution is now a supported API. New subpath @tv/extension-sdk/i18n exports resolveLocale() plus the storage keys the platform agrees on (LOCALE_STORAGE_KEY, RESOLVED_LOCALE_STORAGE_KEY, LEGACY_LOCALE_KEYS, LOCALE_LOOKUP_ORDER, FALLBACK_LOCALE). A federated module can follow the shell's language without writing its own detector.

    This closes the same class of gap the building-selection API closed in 1.2.0. Five modules — service-desk, leases, comfort, parking, room-booking — had each hand-written a detectLanguage() that reads the shell's localStorage key, copied from one another and already drifting. A module reading the wrong key does not fail loudly; it simply stays in the old language while the shell switches, which is how service-desk sat in Russian inside an English shell without anyone noticing. Thirty-three copies of a policy is thirty-three pull requests per change, so the policy moves here and a change becomes a version bump.

    Modules never ask the browser. resolveLocale() reads storage and nothing else. A remote that called navigator.language itself could disagree with the shell it renders inside — an English panel on a Russian page — and neither half would look wrong in isolation. One detector, in the shell; everyone else follows it.

    Two keys, deliberately. tv-platform-locale means the user explicitly picked a language; tv-platform-locale-resolved means this is what the shell is currently rendering, detection included. i18next's default caches: ['localStorage'] collapses the two — the first visit persists a detected language as though it had been chosen, and the browser's language is then ignored forever after. Keeping them apart is what lets an explicit choice outrank detection permanently while detection still reaches modules.

    Region tags fold only when they have to: en-US resolves to an en catalogue, while pt-BR stays pt-BR rather than collapsing into a pt catalogue nobody ships. A stored language the module has no catalogue for is rejected rather than rendered, because fallbackLng would otherwise fill every key from the fallback catalogue and produce a fluent-looking page in the wrong language. Storage that throws — private mode, embedded webviews, locked-down kiosks — yields the fallback instead of taking down the federated route over a rendering preference.

[1.2.0] — 2026-08-04 ​

Added ​

  • Building selection is now a supported API. @tv/extension-sdk/context gains readBuildingSelection(), subscribeBuildingSelection() and isSameBuildingSelection(); @tv/extension-sdk/react gains useSelectedBuildingId() and useBuildingSelection(). A federated module can now follow the Building OS header selector — including reacting to changes while mounted — without parsing the host's storage itself.

    This closes a gap that had every module solving it privately. PlatformContext.building was the documented answer, but it travels through React Context, and React Context does not cross a Module Federation boundary unless @tv/extension-sdk is a shared singleton in both the host and every remote — which it is not. usePlatformContext() therefore throws inside a federated module even though the shell renders <PlatformProvider>, so it had zero module consumers, while five modules (cafm, equipment, leases, service-desk, bim) had each hand-written a parser for the shell's building-storage key and its tv:building-selection-changed event. The SDK now owns that read: the storage key and event name become the SDK's problem rather than each module's, and the same call keeps working when the shell later moves to a shared-singleton context.

    BuildingSelectionEntry.organization is optional and parsed all-or-nothing — a half-populated value is dropped rather than rendered as a confident owner label. Older shells persist only {id, name}; the field is here from the start so consumers can render the owning organization as soon as a host publishes it, without waiting on another SDK release. It matters wherever building names are not unique: a platform admin sees several organizations' buildings at once, and production has more than one set sharing a name.

    The new functions do what the hand-written copies did not: they de-duplicate notifications so a 2-second fallback poll no longer re-renders consumers on every tick, they recover the primary building when only the legacy scalar id was persisted, and they reject the literal string "undefined" that reached the store in the wild.

Changed ​

  • README no longer teaches useBuilding() as the way to scope a federated module to a building — that example did not work in the environment it was written for. It now leads with useSelectedBuildingId(), states that Building OS has exactly one building selector and modules must not ship a second, and keeps the PlatformContext example scoped to code running inside the shell bundle.

[1.1.0] — 2026-08-01 ​

Minor rather than patch: the fixes below are patch-level, but --scope adds a CLI flag and a ScaffoldInput field. Additive surface bumps the minor.

The headline is that manifest.schema.json is usable for the first time. Until now it was emitted in output mode and rejected 30 of the 31 manifests actually shipping, so $schema had to be left out of module manifests entirely. On this version you can point $schema at it and get editor validation that agrees with tv-sdk validate.

Fixed ​

  • check-exposes no longer reports a false "Missing canonical expose ./Shell" on modules that are correct at runtime (#31). It reads vite.config.ts as text, and two static-analysis faults compounded: the entry regex matched only quoted keys, so a computed [`./${EXPOSE_COMPONENT}`] never registered; and the exposes: { … } block was sliced with a non-greedy /\{[\s\S]*?\}/ that stopped inside ${EXPOSE_COMPONENT}, dropping every later entry once one used a placeholder. Simple single-file const NAME = 'literal' declarations are now folded in before matching, the entry pattern accepts template literals and the bracketed computed form, and a brace-balanced scanner (skipping strings, template literals including nested ${}, and comments) replaces the regex. Anything still unresolvable is reported rather than silently dropped, and a missing ./Shell alongside unresolved keys is a warning, not an error.

  • manifest.schema.json is now emitted in input mode. It was generated with Zod's default io: 'output', which describes a manifest after parsing — so every field carrying a .default() (sdkVersion, buildingTypes, capabilities, mcpTools, lifecycle, plus ui.remoteEntry, ui.dashboardWidgets, ui.defaultLandingByRole, navigation[].roles, mcpTools[].permissions) was published as required. The artifact exists so consumers can point $schema at it for editor validation, where that is exactly backwards: it demanded the fields the validator exists to supply. Measured against tv-platform, the published schema rejected 30 of 31 shipping manifests — including tv-module-example, the reference implementation — while tv-sdk validate accepted all of them. The mismatch went unnoticed because the $schema URL the docs advertised 404'd. The schema and the Zod validator now agree exactly; the 5 manifests that still fail do so under both, and are genuine manifest bugs. Regenerated. New src/manifest/generate-schema.test.ts locks the invariant so a future emitter change can't silently reintroduce it.

  • init-module / init-manifest scaffolder defaults corrected. All three were wrong for the external module authors the developer portal now routes to this command:

    • sdkVersion was the hardcoded literal "0.1.0", stale since before the 1.0 contract. Now stamped from the package's own version at scaffold time. The platform gates on this field when deciding whether it can run a module, so a stale value is not cosmetic.
    • $schema was "../../packages/tv-extension-sdk/manifest.schema.json", which only resolves from inside the tv-platform monorepo and dangled for everyone else. Now "./node_modules/@tv/extension-sdk/manifest.schema.json", which resolves for anyone who installed the SDK (pnpm symlinks the workspace package into the same path). Useful again now that the schema itself is input-mode.
    • The module id hardcoded Tango Vision's own @tv scope, so a third-party module was scaffolded into a scope it must not publish under.

Added ​

  • --scope=<scope> on init-manifest and init-module (and ScaffoldInput.scope), with or without the leading @: --scope=acme produces @acme/module-<slug> in both the manifest id and the generated package.json name. Defaults to @tv for first-party use — unchanged behaviour for existing callers.

Changed ​

  • init-module's "Next steps" output now says pnpm install / pnpm dev. pnpm is the platform standard; the scaffolder was the one place still telling authors to use npm.

[1.0.6] — 2026-07-24 ​

Fixed ​

  • Manifest schema: event names may now contain underscores within dot-separated segments ([a-z0-9_-], segment must still start with a letter or digit). The old pattern rejected the platform's own first-party catalog events — building.element.status_changed, and the new service-desk.ticket.sla_warning / sla_breached — so tv-sdk validate failed on any manifest that declared them (hit while updating tv-module-cafm's manifest). Module/capability ids and permission subjects keep the stricter kebab-case pattern; only events.publishes[].name / events.subscribes[].name are affected. manifest.schema.json regenerated.
  • buildSubject() had the same gap: it threw Invalid eventType for snake_case event types, including the catalog's own building.element.status_changed. NATS subject tokens permit _, so the guard now allows it (wildcard/injection characters are still rejected).

[1.0.5] — 2026-07-19 ​

Changed ​

  • permissions.snapshot.json regenerated from tv-api's PermissionRegistry (#11) — adds the building.models and building.payments subjects. Additive only: no existing subject or grant was removed. This version bump is what actually ships the regenerated catalog to consumers — the snapshot was regenerated in place in 1.0.4 without a version bump, so the published 1.0.4 tarball still carried the pre-regen catalog and installs never saw the new subjects.

[1.0.4] — 2026-07-17 ​

Fixed ​

  • tv-sdk write-version now reads the TV_APP_COMMIT env var when --commit is not given, before falling back to git rev-parse HEAD. The standard module Dockerfile declares ARG TV_APP_COMMIT + ENV TV_APP_COMMIT=$TV_APP_COMMIT expecting exactly this, but .git is dockerignored, so every containerized build stamped "commit": "unknown" into version.json. Resolution order is now --commit flag → TV_APP_COMMIT env (non-empty) → git rev-parse HEAD → "unknown" — the same precedence building-os uses for its __TV_APP_COMMIT__ build constant.

[1.0.0] — 2026-05-30 ​

First stable release. The surface tagged in Phase 2.1 (see STABILITY.md) is now binding under semver: @stable exports carry a 6-month deprecation guarantee; @experimental exports may still change in any release.

No breaking API changes from 0.2.0 — the move to 1.0 is the commitment to the surface, not a reshaping of it. Existing consumers upgrade by bumping the version; review the @experimental list below and decide which symbols to track changes on.

Added ​

  • Stability tiers documented: every subpath barrel now carries a @stable or @experimental JSDoc tag. See STABILITY.md.
  • @tv/extension-sdk/permissions — permission catalog extractor + types (extractCatalog, extractCatalogFromFile, PermissionCatalog, SubjectEntry). Phase 2.4.
  • tv-sdk snapshot-permissions CLI command — extracts the catalog from tv-api's PERMISSION_ROLE_MAP source. Phase 2.4.
  • permissions.snapshot.json artifact ships with the package (17 subjects, 33 actions as of 2026-05-25).
  • @tv/extension-sdk/sandbox — tv-sdk sandbox CLI subcommand family (create / list / connect / extend / delete / reset). Phase 1a.4. @experimental — surface may grow during Phase 1a rollout.

Changed ​

  • Build moved off zod-to-json-schema (Zod 3-only) onto Zod 4's built-in z.toJSONSchema(). Affects manifest.schema.json + tv-events.snapshot.json outputs — same contract, normalized representation. CI's check-events self-consistency passes against the regenerated snapshot.
  • tsconfig.json adds "types": ["node"] so node:fs / process / etc. resolve. Closes silent SDK build break that started 2026-05-01.
  • z.record() call sites updated to Zod 4's two-arg form (z.record(z.string(), …)).
  • manifest.schema.json regenerated under Zod 4's emitter — more strict (more required fields surfaced). Downstream consumers using $schema for editor validation should re-validate their manifests.

Removed ​

  • zod-to-json-schema dependency (no longer used).

Stability tiers in this release ​

Already @stable (no churn expected within a major):

  • @tv/extension-sdk/manifest — schema + validator + scaffolders + checkExposes
  • @tv/extension-sdk/context — PlatformContext types
  • @tv/extension-sdk/events — catalog, envelope, subjects
  • @tv/extension-sdk/api — PlatformApiClient types
  • @tv/extension-sdk/react — provider + core hooks
  • @tv/extension-sdk/nestjs — @ModuleCapability(), @RequiresLicense() decorators
  • @tv/extension-sdk/testing — createMockPlatformContext
  • @tv/extension-sdk/permissions — extractor + catalog types
  • CLI: validate, check-exposes, init-manifest, init-module, write-version, snapshot-permissions
  • JSON artifacts: manifest.schema.json, permissions.snapshot.json

@experimental (may change in any release until graduation; see STABILITY.md):

  • @tv/extension-sdk/heartbeat
  • @tv/extension-sdk/version-check
  • @tv/extension-sdk/sandbox (CLI subcommands)
  • Manifest fields: PayloadContract, McpToolDeclaration.permissions
  • CLI: check-events, check-api, check-pact, snapshot-events, snapshot-api
  • React: useVersionCheck, <UpdatePrompt>
  • NestJS: TvHeartbeatModule.register()

[0.2.0] — 2026-04 (historical reference) ​

Initial SDK consolidation. ModuleManifest schema + PlatformContext + React/NestJS integration. See git history for details.

Migration guide: 0.x → 1.0 ​

When 1.0.0 ships (Phase 2.2), the surface above becomes binding under semver. There are no breaking API changes between current pre-1.0 and the planned 1.0 — only the addition of the stability tiers. Existing internal consumers should not need code changes; they should review the @experimental list above and decide which symbols to track changes on more carefully.

Built on the Tango Vision platform. Questions? developers@tango.vision