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.hiddenbeats the shell'slg:flex, and one remote's.flex-colbeats another'ssm: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
.cssasset 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
@propertyfallbacks) stay global.@keyframes,@font-faceand@propertyare left alone. - Pass the same
namethe 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.
- The plugin rewrites every emitted
postcssis 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
McpSignatureGuardchange 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.eventBustravels through React Context, which does not reach a remote, sousePlatformContext().eventBusthrew inside Building OS, and the events guide showed exactly that code. Building OS now putsevents.subscribe()onwindow.__TV_PLATFORM__(tv-building-os#225), and these wrap it:usePlatformEvent(type, handler)in@tv/extension-sdk/reactsubscribes while the component is mounted, and calls the latest handler without resubscribing.subscribePlatformEvent(type, handler)in@tv/extension-sdk/contextdoes the same outside React and returns an unsubscribe function.isPlatformEventsAvailable()says whether a shell delivers events.PLATFORM_EVENT_TYPESlists the seven types.- Payloads are typed (
PlatformEventPayloads):building.*from the event catalog, and the fourwifi-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/testinginstalls a stand-in withemit()for a module's own tests. It refuses the types the shell refuses and copies payloads the same way.PlatformGlobalgains an optionalevents.
createPlatformApiClient()in@tv/extension-sdk/context(experimental): aPlatformApiClienta federated module can actually get.PlatformContext.apitravels through React Context and does not reach a remote, so inside Building OS a module had noPlatformApiClient, andcreateBuildingGraphClient()takes one. The README's own graph example got it fromusePlatformContext(), which throws in a remote. The new client runs overfetch, asksgetAccessToken()for the token on every request, and sends paths relative to the page, where Building OS serves/apiin production and in sandboxes. SocreateBuildingGraphClient(createPlatformApiClient())works inside the shell, and so doesapi.get('/api/v1/…')for any other endpoint.- Query values that are
undefinedare left out. - Bodies are sent as JSON, except
FormData,Blob,URLSearchParamsandArrayBuffer, 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, withstatus,method,urland the parsedbody; its message includes tv-api'smessage(validation messages joined). - The options (
baseUrl,getToken,headers,fetch) and the error's fields are@experimental; the returnedPlatformApiClientis the stable interface.
- Query values that are
graph.buildings.get(buildingId)returnsBuildingDetail: the building with its organisation, site, building type, storeys ordered by level and_count, which is whatGET /api/v1/buildings/:idanswers. The matching key isbuildingGraphKeys.buildingDetail(id), nested underbuildingGraphKeys.building(id)like the rest.- A module's sidebar label can be translated:
ui.navigation[].labelLocalesand top-levelnameLocalesin 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 (
ptforpt-BR), then the plainlabel. - Before this,
tv-sdk validate, andsandbox 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.jsoncarries both fields, so editors autocomplete them. The newLocalizedStringsschema and type are exported from@tv/extension-sdk/manifest.
Fixed
- The event catalog describes what tv-api actually sends for
building.point.updatedandbuilding.alarm.triggered. Both schemas described payloads tv-api has never sent, over NATS or over its browser socket. Code written against them readundefined, andvalidateEventPayload()rejected every real event. Checked against tv-api5511bbb:TelemetryService,FddService, and the/buildingsgateway's field lists (src/gateway/browser-events.ts, tv-api#273).building.point.updatedis now{ buildingId, pointId, value, timestamp }.valueis a string, because tv-api stores readings as sent ("21.5","on").timestampis when the reading was taken.- Gone:
observedAt,quality,unitandelementId, none of which tv-api sends.
building.alarm.triggeredis now{ buildingId, faultId, severity, title }.faultIdis the fault's id, as inGET …/faults.severityis the fault's own open string (critical,high,medium,low, …), as onbuilding.fault.created.- Gone:
alarmId,code,message,acknowledgedAtand theinfo/warning/critical/emergencyenum.
- The exported
PointUpdatedPayloadandAlarmTriggeredPayloadtypes 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 apayloadContracton either event (checked 2026-09-28). tv-events.snapshot.jsonis regenerated.check-eventsagainst the old snapshot reports these corrections as breaking, which is the point of the regeneration.building.element.status_changedandbuilding.changeset.appliedkeep their schemas and now say they are not published: nothing in tv-api raises either. The note onbuilding.element.status_changedalso says thatnewStatuswill carry one of tv-api's element statuses (ACTIVE,INACTIVE,MAINTENANCE,DECOMMISSIONED), with noFAULTorDEGRADED: an equipment fault arrives asbuilding.fault.created(tangovision/tv-api#275).
create,list,connect,extend,deleteandresetnow resolve the provisioner's address and the developer's credential the same wayinstall --sandboxhas since 1.15.1 — neverTV_API_URL, andTV_API_TOKENonly when it is a developer key (tvk_…). 1.15.1 fixed this forinstall --sandboxalone; the other six subcommands went through a second, older copy of the resolution inclient.tsthat still fell back toTV_API_URLand anyTV_API_TOKEN.sandbox connectalready told developers not to export theTV_API_URL/TV_API_TOKENpair 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 newwarnAboutIgnoredProvisionerEnvonce, before its first request, to say what it is ignoring and why — the same class of warninginstall --sandboxalready gave, extended to the rest of the family.sandbox connect's own guidance and the root--helptext are updated to describe the fixed behaviour.
Changed
openapi.snapshot.jsonis regenerated against tv-api8662c18(2026-09-28), throughpnpm 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
GETof 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,storeyIdand 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 threeinfoitems,from/toonhistory/diffandpointIdontelemetry/observations, which were always required and are now documented.
- Responses: the seven read endpoints a module uses (the building, and the list and single
- The graph wire types follow tv-api's response DTOs (tv-api#263,
graph-responses.dto.ts).- Space list and detail type
geometryasGeoJsonPolygon | null; it wasRecord<string, unknown>. - Element list, detail and
view=graphitems gainlocation: GeoJsonPoint | null. ElementgainssiteId,plotIdandroadSegmentId: 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 asElementAnchorFields.ElementDetailgains thesite,building,plotandroadSegmentreferences.- 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.
- Space list and detail type
- CI type-checks the test files. Nothing did before:
tsconfig.jsonexcludes*.test.ts,*.test.tsxand*.spec.ts, sopnpm typechecknever read them, and vitest strips types without checking them, so everyexpectTypeOfassertion (theSerializedand "wire types follow tv-api" tests insrc/graph/client.test.ts) passed whatever it asserted. The newtsconfig.test.jsonextendstsconfig.jsonover all ofsrc/, tests included;pnpm typecheck:testruns it, and thetestjob inci.ymlruns that after the build. A plaintscrun rather than vitest'stypecheckmode, 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 theSerializedassertion:typecheck:testfailed with TS2344 whilevitest runstill passed the file. - The axios fake in
src/graph/client.test.tsdid not matchAxiosLikeInstance: its methods returnedPromise<{ data: { ok: string } }>where the interface's generic methods returnPromise<{ data: T }>, a TS2345 at bothplatformApiFromAxios()calls. Its methods are now generic, with the canned body cast toT.AxiosLikeInstanceis unchanged. No change to the published package. scaffold-canary'spnpm installof a freshly scaffolded module now fails the job if pnpm reports "Issues with peer dependencies". Until nowpnpm installonly 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 generatedpackage.jsonproduces, which is exactly what this job exists to catch (see the job's own comment on the@tanstack/react-queryomission 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 copilotis gone. Hidden from--helpsince 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 outsidesrc/copilot/and the one dispatch branch incli.tsreferenced it, so it is removed rather than repaired:tv-sdk copilotnow prints a clear "no longer available" message and exits 1, instead of running.@anthropic-ai/sdk,@inquirer/prompts,chalk,gradient-stringandoradrop fromdependencies— 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
McpSignatureGuardrefuses every request in production when no signing key is configured (ADR 154, part 1). With neitherTV_PLATFORM_SIGNING_SECRET_<SLUG>norTV_PLATFORM_SIGNING_SECRETset, the guard used to log a warning and returntruefor 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 anyMcpAccessGuard(thetv-module-eventsshape) that hands its HMAC half to this guard. It now behaves exactly as tv-api's ownsrc/common/auth/mcp-signature.guard.ts:NODE_ENV=productionand no key: every request is answered 401 with codeMCP_SIGNING_SECRET_MISSING, and the first refusal is logged once at error level, naming the variable to set. The process still starts and/healthstays 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_SECRETtoday, exceptnotifications-api, whoseTV_PLATFORM_SIGNING_SECRET_NOTIFICATIONScomes 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 anX-Tenancy-Idheader, 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, soresolve()now also takes the incoming request,resolve(req), and forwards both itsAuthorizationand itsX-Tenancy-IdtoGET /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 theOccupantRequesttype. - 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.
- A request without the header sends none, and leases answers with the caller's default tenancy, exactly as before. The old form,
Changed
permissions.snapshot.jsonis regenerated against tv-api06b33e7(2026-09-27). Two changes. tv-api#268 removedtenantfromengagement.promotions:write, which is now granted tomanagerandmarketeeronly;tenantkeepsengagement.promotions:read. The snapshot from tv-api2734d4calso lacked thirteen actions the registry gained since:attendance.policies,attendance.records,attendance.scans,attendance.shiftsandattendance.staff(eight actions),building.copies:read/write, andguard-patrol.alerts:write,guard-patrol.config:writeandguard-patrol.patrols:read. No action was removed.tv-platform'spermission-catalog.ymlcompares 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 loginworks on Windows. Windows Credential Manager caps a secret at 2560 bytes (@napi-rs/keyringcounts the password as UTF-16), and a Keycloak access + refresh token pair is larger, so saving the session threwValue of 'password encoded as UTF-16' is longer than the platform limit of 2560 charsafter 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.jsonwith the usual warning and removes any older keyring entry;loadreads the file whenever the keyring has no entry, since the next process picks the keyring again;logoutremoves both; and a session that fits the keyring again deletes the stale file.TV_SDK_TOKEN_STORE=keyringkeeps 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.tsxthatinit-modulegenerates no longer opens with an unusedimport React from 'react'; the generated tsconfig uses the automatic JSX runtime ("jsx": "react-jsx"). - The generated
vite.config.tspointed readers atdocs/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.tssaid "the shell-integration smoke in CI is what would catch its removal". An--externalmodule's CI has no smoke step (its ownci.ymlsays 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.mdended its Sales Impact note withsee AGENTS.md, "CHANGELOG Required", a section no generatedAGENTS.mdhas. The pointer is gone; the rule it pointed at is stated in the note itself. sandbox install --sandboxreaches the provisioner even when thesandbox connectvalues are exported. The lookup found the provisioner throughTV_SANDBOX_API_URL, thenTV_API_URL, and authenticated withTV_API_TOKENahead of the login session — andTV_API_URL/TV_API_TOKENare exactly the pairsandbox connectprinted asexportlines, which the developer walkthrough ran one line beforeinstall. 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_URLor the deployed default, and authenticates with--token, elseTV_API_TOKENonly when it holds a developer key (tvk_…), else thetv-sdk loginsession. ATV_API_TOKENit skips, and aTV_API_URLit 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 thetvs_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 thesandboxcommands still read both variables as before.--tokenpassed with--sandboxis no longer sent to the sandbox. It authenticated the lookup and was then also used, ahead of the record's ownapiToken, for the registry-ingest and licence calls — so atvk_key passed that way was a guaranteed 401 on ingest. Those calls now always use the sandbox's own token from its record.sandbox installerrors 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 runlogin(a sandbox token passed as--tokenis 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=jsonerrors keepstepand addstage(lookup/ingest/buildings/licence),serviceandurl.sandbox <subcommand> --helpprints the usage and exits 0. It answered "Unknown flag: --help" with exit 2 — including forsandbox create --help, the commandsandbox listitself suggests when you have no sandboxes. Theconnectline of the usage no longer promises environment "fortv-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 tokensandbox connectshows is for server-side code, neversrc/or a commit. - The standalone page of a scaffolded module no longer goes blank the moment the Shell calls
useQuery.src/main.tsxwraps the tree in aQueryClientProvider— outermost, where Building OS has its own — so the first example in the developer guide renders inpnpm devinstead 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-queryjoins the federationsharedlist 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
lintscript that cannot run. The generatedpackage.jsonpinnedi18next ^25againstreact-i18next17's peer of>= 26.2.0, and@tv/ui ^1.4.1against this SDK's peer of>= 2, so the firstpnpm installof every module warned about our own packages;i18nextis now^26.2.0and the first-party scaffold carries@tv/ui ^2.2.0and@tv/design-tokens ^3.2.0(the version@tv/ui2 depends on)."lint": "eslint ."came with no ESLint and no config, sopnpm lintfailed with "eslint: command not found" in every fresh module; the script is gone, and the README and the externalAGENTS.mdsaypnpm typecheckis the static check.
Changed
init-modulescaffolds for Node 24. Both generated workflows ranactions/setup-nodeatnode-version: '22'while the generatedpackage.jsondeclaredengines.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 formodule.registerHooks(below it the harness fails loudly rather than skipping), and Building OS itself builds onnode:26-alpine, so 20 was never a floor anything verified.engines.nodeis now>=24.0.0and 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), whichbuild-push.ymlcompares every Dockerfile against, so raising it here alone would turn each new module's first build red while leaving the fleet on 10.Every
sandboxcommand that takes an id says where to find it.connect,extend,delete,resetandinstall --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 thecreateoutput gotMissing required argument: <id>or a bareHTTP 404and nothing more. Thesandbox --helptext 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 thatcreateprints the id andnpx @tv/extension-sdk sandbox listshows every id you own. JSON output carries the same text ashint.The human output of
sandbox create,listandconnectshows the sandbox'sslugandorganizationId. 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—, notnull.sandbox createends 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 thesandbox 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 besandbox connect <id>, the command whose output was the trap above. Thesandbox --helpexample and the scaffolded README give the command on one line too.sandbox connectprintsTV_API_URL/TV_API_TOKENas server-side settings, not shell exports, and keeps the token out of browser code. They are printed asKEY=valuelines 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 withgetAccessToken()/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 thatinstall --sandboxneeds neither.Scaffolded modules declare
"packageManager": "pnpm@10.18.2", the platform pin the fleet's ownpackage.jsonfiles 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 andAGENTS.mdrequire. The external CI keeps its explicitversion: 10.18.2;pnpm/action-setupfails the job if the two ever disagree.init-module --externalno 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--authornames one, instead of "Tango Vision" (an--author-emailor--author-urlwithout--authoris refused rather than filed under Tango Vision); the default descriptions and the standalone page title say "Building OS" rather than "Tango Vision"; the generatedCHANGELOG.mddoes not ask for Tango Vision's Sales Impact copy; andpackage.jsondrops@tv/uiand@tv/design-tokens, which nothing in the scaffold (or this SDK) imports.init-moduleprints the id and author it wrote, and--author/--descriptionare in--help.The
--externalscaffold cites nothing its author cannot open, and promises no gate their repository does not run..gitignoresaid "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 privatetv-module-*repositories invite.config.ts,README.md,AGENTS.md,ci.ymlandpnpm-workspace.yamlare 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.tslists the files that differ.init-module's next steps pass--sandbox <your-sandbox-id>tosandbox install, which otherwise stops with "Cannot tell which sandbox to install into". The root--helplistsinstallamong thesandboxsubcommands.tv-sdk copilotis 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-canarystill tests pnpm 11. WithpackageManagerin the scaffold, itsnpx pnpm@11.18.0 installwould 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()andresolveAccessToken()in@tv/extension-sdk/context— the supported way for a federated module to authenticate its own HTTP calls. A module has had none:PlatformContextdoes not cross the federation boundary (the SDK is not in the shell's shared scope, sousePlatformContext()throws in a remote), and the fleet improvised two. Measured across all module frontends on 2026-09-21, 13 readlocalStorage['access_token']— a copy the shell'sLegacyTokenBridgewrites for them, against the shell's own policy of keeping OIDC tokens out of localStorage — and 11 parse oidc-client-ts's privateoidc.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 theoidc.user:*scan only for a shell that predates the accessor — sessionStorage first, skipping an entry whoseexpires_athas passed. When the shell is present itsnullis authoritative: a stale storage entry must not paper over a sign-out. It deliberately does not fall back tolocalStorage['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-modulegenerates 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 atuseSelectedBuildingId()andgetAccessToken().init-modulesharesreact-routerandreact-router-domwith the host, so a routed Shell works inside Building OS. The generatedsrc/Shell.tsxtold the author to "mount [pages] via react-router inside this component", while the generatedvite.config.tsshared onlyreactandreact-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 withCannot destructure property 'basename' of 'React.useContext(...)' as it is nullunder the old shared list, PASS with "index route" rendered under the new one. Both packages are shared, like the shell's own list and liketv-module-leases/tv-module-cafm(@tanstack/react-querystays 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 norequiredVersion, because@originjs/vite-plugin-federationthen takes whatever copy the host provides (array-formsharednever 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 generatedpackage.json, since a shared name absent from it fails the build; a test now asserts every shared name is a declared dependency.src/main.tsxwraps<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 inpnpm 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-modulenames the federation containercamelCase(slug), nottv-module-<slug>. The platform contract (MODULE_FEDERATION.md, "Container naming contract") is thatui.federationNameequals the remote's container name, and tv-api defaults that field tocamelCase(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-industrialshipped that way and had to be renamed by hand (tv-module-industrial#1).slugToCamelCaseis exported. Existing modules are unaffected: this changes generated output only.
Changed
permissions.snapshot.jsonis regenerated against tv-api2734d4c(2026-09-23). The committed snapshot dated from tv-apie48deb1(2026-09-06) and lacked eleven actions the registry has gained since:copilot.llm-config:read/write(tv-api#246), the sixmultimedia.*rows (tv-api#247), andplatform.roles:read/writeandtelemetry.observations:write(tv-api#252). No existing grant changed. This is the filetv-platform’spermission-catalog.ymlcompares 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-moduleoutput from 1.14.0 could not be built at all, in either mode:pnpm install && pnpm run buildfailed with"useQuery" is not exported by "__vite-optional-peer-dep:@tanstack/react-query:@tv/extension-sdk"and emitted nodist/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'ssrc/main.tsximportsPlatformProviderfrom@tv/extension-sdk/react; that barrel is@stableand also exports<LocationPicker>, whoseimport { useQuery } from '@tanstack/react-query'names an optional peer of the SDK;vite buildbundlesindex.html→main.tsx, and rollup resolves every import of a barrel it pulls in long before it tree-shakes. The generatedpackage.jsonnow declares@tanstack/react-query: ^5.100.0.The dependency, not the barrel. Splitting
LocationPickerout of@tv/extension-sdk/reactwould remove a documented@stableexport from a@stablesubpath (STABILITY.md), which a patch release may not do; pointingmain.tsxat 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 forQueryClientProviderin each; zero hits).The
sharedmap is unchanged, and says why.@tanstack/react-queryis in Building OS's own federationsharedscope, 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 generatedvite.config.tsnow carries that reasoning beside the map, together with the rule it implies: render<LocationPicker>— or calluseQueryyourself — and you must add the entry, because React context does not cross the remote boundary for an unshared package, so the host'sQueryClientProviderwould never reach it.scaffold-canarynow builds what it scaffolds, in both modes. The job carried an explicit exemption — "Deliberately NOT covered: a fullvite buildof the scaffold (it emits no tsconfig.json or index.html, by design)" — whose premise 1.14.0 had already falsified, since the scaffold emitsindex.html,tsconfig.jsonandsrc/main.tsx. Its only compile wastsc --noEmit … src/Shell.tsx, which cannot see this class of defect twice over: it starts atShell.tsxand never reachesmain.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 ownbuildscript against the tree the scaffold declares — nothing installed for the occasion — and fails unlessdist/assets/remoteEntry.jsexists, because a federation config that quietly stops emitting a remote entry leaves a greenvite buildand an unloadable module. Amode: [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-moduleemits the build-watch/preview pair, a pinned remote port, and a README that names the URL. The generatedvite.config.tscarried aserverblock and nothing else, so the author's natural first move —pnpm dev— served the app on 5001 and emitted nodist/assets/remoteEntry.jsat all:@originjs/vite-plugin-federationproduces 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 carriespreview: { port, strictPort: true, cors: true }, andpackage.jsontwo scripts —dev:remote(vite build --watch) andserve: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 ispreviewPortFor(devPort), the dev port plus 1000 (default 6001), so a module that moved its dev port moves its remote with it;strictPortbecause 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 andREADME.mdis 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/ingestwith{ manifest, remoteEntryUrl }, thenPOST /api/v1/licenseson the seeded building — because the shell renders from licence state, so a registered-but-unlicensed module is simply absent.--remote-entryis what makes it useful: it sets the registry row'sremoteEntryUrl, 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 theTV_API_URL/TV_API_TOKENpairsandbox connectprints 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/:slugroute driven by the registry row'sui.remoteEntryUrl,ui.federationNameandui.exposeName(tv-building-os#196), never the production shell's/<slug>.federationNameis sent only when the manifest names one, because tv-api defaults it tocamelCase(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-seedhas carried auniversitytemplate since the university demo stand (itsBuildingTypeenum is exactlymall | office | university), and the provisioner and the docs take it, so a developer who asked for one gotInvalid --typeand 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 outsidetangovision— it cannot run: the generated.github/workflows/ci.ymlasks forruns-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 emptyrunner_name), and calls three composite actions out of the privatetangovision/infrastructure, each of which fails the job at start with "repository not found";.github/dependabot.ymlauthenticates to the private registry withNPM_READ_TOKEN, an organisation-level Dependabot secret that resolves to an empty string elsewhere so every npm run fails on auth; andAGENTS.mdcarries the org platform-rules block, most of which describes machinery an outsider cannot reach. With the flag: CI onubuntu-latestwithpnpm/action-setup@v4pinned 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 ownTV_NPM_TOKENrepository secret, masked with::add-mask::and written to an npmrc under$RUNNER_TEMPviaNPM_CONFIG_USERCONFIG, never~/.npmrc, then install,tv-sdk validate, typecheck, tests, a real federation build andtv-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 anAGENTS.mdwithout the platform-rules block — omitted entirely rather than trimmed, because CI compares that block byte-for-byte againsttangovision/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, theuse-sync-external-storeshim, 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 fromorigin/mainand 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.tsxwraps the standalone entry in a mock platform context. The SDK docs advertisecreateMockPlatformContext()from@tv/extension-sdk/testingas how you develop a module outside the shell, and the scaffold emitted a barecreateRoot(<Shell/>)that used none of it — so an author who reached forusePlatformContext()met a thrown error inpnpm devwith nothing pointing at the cause. The entry now renders<PlatformProvider value={createMockPlatformContext()}>, with an override example inline. It wraps only this entry, andShell.tsxstill takes no provider: React context does not cross the federation boundary here, because@tv/extension-sdk/reactis not in thesharedmap — the remote bundles its own copy with its owncreateContext()object, so the provider Building OS mounts populates a different context than the one the remote reads andusePlatformContext()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-modulewrites intoAGENTS.mdgains one rule: every change reaches the default branch through a pull request, never agit pushontomain/master, admin or not, and a gitlink SHA is resolved from the remote, never typed or expanded from a 7-character abbreviation.PLATFORM_RULESinsrc/manifest/scaffold.tsis re-synced verbatim fromtangovision/templates/PLATFORM-RULES.md, which theplatform-rulesCI 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-wideAGENTS.md.
Added
validateManifestreports review flags;tv-sdk validateprints them. A successful result gainsreviewFlags: 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 validatelists them under the success line and still exits 0;--format=jsoncarries them asreviewFlags. Additive:ok,manifestandissuesare unchanged.A flag, deliberately not a rejection.
PermissionCatalog.reservedPrefixeshas 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";reviewFlagsis the list that reviewer reads, and a flagged request with noreasonsays 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[].permissionsare 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 pullingnode:fsin throughextract.ts; a test pins the catalog'sreservedPrefixesto the same list.
[1.13.0] — 2026-09-11
Added
init-modulenow scaffoldsCHANGELOG.md. TheAGENTS.mdthis same command writes told the author twice to maintain a file it never created — once in the platform-rules block ("every deployable has aCHANGELOG.md(Keep a Changelog,## [Unreleased])") and once in the module section ("UpdateCHANGELOG.mdunder## [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 livetv-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 newscaffoldChangelog()insrc/manifest/scaffold.tsemits 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 Impactis 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-modulenow 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 newscaffoldDependabotYml()insrc/manifest/scaffold.tsemits the sameregistries: { tv-verdaccio }block,NPM_READ_TOKEN-from-org-secret wiring, andopen-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/templatesgets 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.ymlnow 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 noignore:list andopen-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()insrc/manifest/scaffold.tsnow emits the fleet reference shape (tangovision/tv-module-leases/.github/dependabot.yml): updates on at limit 10 with aminor-and-patchgroup and adependencieslabel, and majors held forreact-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 removesdatasource.urlfromschema.prisma, P1012 — a schema migration, and until it is done every prisma 7 PR failsprisma generate), and@nestjs/*(Nest 12 ships ESM-only packages, so the fleet's CommonJS Jest setup failsapp.module.spec.tswith "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+/frontendpair) and nodockerecosystem, because the scaffold ships no Dockerfile — when it gains one it will build FROMpublic.ecr.awslike the rest of the fleet, where Dependabot's proxy is refused (DENIED, then HTTP 429) and the job fails every run. Agithub-actionsentry ignorestangovision/infrastructure*: the generatedci.ymlcalls three composite actions out of that private repo, which Dependabot's repo-scoped token cannot read, so every run would otherwise reportgit_dependencies_not_reachableon 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 thebackend/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/templatesis kept in step in a companion pull request — templates#67 had already held@nestjs/*in that repo's own root config and in thebackend-nestjsscaffold, 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.registrypointed 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@tvorg page → 403). Every real publish has always gone to the private Verdaccio (https://npm.k8s.tangovision.dev/) — manual publishes because the@tv:registryscope mapping in~/.npmrcoverrides bothpublishConfigand an explicit--registryflag for scoped packages (verified during the 1.11.0 release:npm publish --registry=https://registry.npmjs.org/still PUT to Verdaccio), and CI publishes becausepublish-extension-sdk.ymlintv-platformgets its registry fromactions/setup-node'sregistry-url+scope: '@tv'inputs, which write the same kind of scope-specific mapping — not frompublishConfig. 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.registrynow readshttps://npm.k8s.tangovision.dev/, matching every other reference to the registry in this repo (README, AGENTS.md). Checked and unaffected: the generated.npmrctemplate (scaffoldNpmrc()insrc/manifest/scaffold.ts) intentionally keeps a bareregistry=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 otherpublishConfigblock 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/OccupantScopein@tv/extension-sdk/nestjs(@experimental). The occupant-mode adapter (ADR 106, decision 3): classify a verified caller as occupant-only (ADR 097'stenantarchetype with no staff archetype), then resolve their tenancy - tenant, building, live-lease space ids, role - from the leases module'sGET /api/tenant/scopewith 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 intv-module-service-deskandtv-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 createnow polls the provisioner instead of holding one HTTP request open for the whole provision. The provisioner answersPOST /api/v1/sandboxeswith202 Acceptedand a record inprovisioningstatus (tv-platform#658); the CLI pollsGET /api/v1/sandboxes/:idevery 5s (progress line every ~30s in human mode, silent in--format=json) until the status turnsreadyorfailed, giving up with exit 2 after 15 minutes — provisioning continues server-side andsandbox list/sandbox connect <id>pick it up later. Old synchronous provisioners still work: areadyresponse 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 viaGET /api/v1/sandboxesand 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 createno 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 logincompletes the OAuth 2.0 Device Authorization Grant (RFC 8628) against thedevelopersKeycloak realm: it opens the browser to the verification page (printing the URL and user code as the fallback —--no-browserskips 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.jsonwith0600permissions and a stderr warning;TV_SDK_TOKEN_STORE=file|keyringforces the backend —fileis the right setting for CI,keyringturns a silent fallback into a hard error. The flow requestsoffline_accessdeliberately: 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).logoutrevokes the refresh token server-side (RFC 7009) and clears local storage even when the realm is unreachable;whoamiproves the whole chain by round-tripping storage → refresh → userinfo. Tokens are never printed. The Keycloak side is a dedicated public clienttv-sdk-cliwith ONLY the device grant enabled (standard flow and direct grants off) and the samedeveloper-apiaudience mapper asdeveloper-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.sandboxlive subcommands now actually call the deployed provisioner.list,connect,extend,delete,reset, and livecreatewere "service is not yet deployed" stubs from Phase 1a.4; they now callhttps://sandbox-api.k8s.tangovision.dev(override with--api,TV_SANDBOX_API_URL, orTV_API_URL, in that order — the localhost default is gone now that the service is real). Credential resolution, in order:--tokenflag,TV_API_TOKENenv, then the storedloginsession with automatic token refresh — so a logged-in developer runsnpx @tv/extension-sdk sandbox create …with no env setup at all, while existing API-key workflows keep working unchanged. New--ttl-days=Nflag oncreate(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-modulescaffolds 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 ofuseSyncExternalStorefromreact— a plainexport { x } from 'react're-export is NOT rewritten by the federation plugin and reintroduces the second-React crash), the matchingresolve.aliasforuse-sync-external-store/shimin the generatedvite.config.ts, and a generated.github/workflows/ci.ymlwhose 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 generatedtsc -b && vite build/tsc --noEmitscripts had nothing to run against),index.html+src/main.tsx(the standalone dev entryvite buildneeds),.npmrc(without it the firstpnpm installresolves@tv/extension-sdkagainst npmjs and 404s), andsrc/locales/catalogues.test.ts— the key-parity test the generatedsrc/i18n.tsdocblock 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-moduleemits a.gitignore. A freshly scaffolded module had none, so its very first commit could takenode_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 runsgit 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.envfiles, and deliberately do NOT ignorepnpm-lock.yaml: the generated CI installs with--frozen-lockfile, which fails on a repo without a committed lockfile. Exported asscaffoldGitignore(). (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.overridesreplaces, rather than merges. Re-syncedPLATFORM_RULESfromtangovision/templates/PLATFORM-RULES.md(templates#32). The override-floor bullet previously stopped at "pnpm-workspace.yaml, notpackage.json", which is correct advice and still misses the way the floors actually came off: a non-emptypnpm.overridesin an install root'spackage.jsonreplaces that root'spnpm-workspace.yamloverrides: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-workspacedoes not readpnpm-workspace.yaml— but then they must be kept byte-identical, andERR_PNPM_LOCKFILE_CONFIG_MISMATCHmust 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 13package.jsonfiles and made six workspace blocks inert this way; intv-module-notificationsadeepmerge-tsCVE floor silently weakened from^8.0.1to>=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 theoverride-source.ymlgate that detects it:tangovision/infrastructure/docs/ci-gates.md#the-pnpm-override-source-invariant(infrastructure#414).Scaffolded modules pin
react-i18nextat^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.buildingTypesdocuments where its vocabulary is decided. The field's slugs are validated at ingest, against tv-api'sbuilding_typesregistry — the platform's single source of truth for building types (ADR 097 layer 2, seeded in tv-api#162).POST /api/v1/registry/modules/ingestnow 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_moduletool and the RU contracts doc invoke the CLI by its full package name. Both used barenpx tv-sdk— the doc as an instruction, the copilot as a string passed toexecSync. The package nametv-sdkis unclaimed on public npm (npm view tv-sdk→ 404), and outside a directory with@tv/extension-sdkinstalled locally, npx resolves a bare name against registry.npmjs.org — so a squatter publishingtv-sdkwould 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 spellnpx @tv/extension-sdk <cmd>, which resolves through the authenticated@tvregistry 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), andinfrastructure/docs/ci-gates.mdin 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
logoutrevokes 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-moduleemitsAGENTS.mdandCLAUDE.md. The AGENTS.md opens with the platform rules fromtangovision/templates/PLATFORM-RULES.md(English only, callbuild-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 theceomonorepo. Exported asscaffoldAgentsMd(),scaffoldClaudeMd()and thePLATFORM_RULESconstant.
[1.8.0] — 2026-08-20
Added
building.fault.createdjoins the event catalog. tv-api's FDD engine has emitted this event since the fault/work-order bridge landed —src/fdd/fdd.service.tspublishes it immediately afterbuilding.alarm.triggeredwhenever a diagnostic rule opens a fault — but it was never declared inEVENT_CATALOG, so it was absent fromtv-events.snapshot.jsonand invisible to every piece of tooling built on that snapshot.The visible symptom was in
check-pact:@tv/module-cafmdeclares a fullpayloadContracton it (Auto-create a work order from an FDD fault) and was toldEVENT_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.publishroutes it totv.building.<buildingId>.building.fault.created, which is exactly the subject CAFM'sTicketBridgeServicesubscribes to), and CAFM's handler, dedup path,faultIdcolumn and unit tests are all in place. Only the declaration was missing. tv-platform'smanifest-fleet-gatecarried 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,titleanddetectedAtare required (all non-null in thefaultstable, anddetectedAtdefaults tonow()), whileelementIdandpointIdare optional (both nullable columns, emitted as?? undefined).severityis deliberately an openz.string().min(1)and not an enum likebuilding.alarm.triggered's. Thefaults.severitycolumn is a freeStringwritten by rule definitions, and tv-api's own summary code guards withif (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 falsePRODUCER_EMITS_UNHANDLED_ENUMsignal. 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-moduleemits apnpm-workspace.yamlwith the fleet's build-script approval lists. A freshly scaffolded module was born unable to run the verypnpm installthe CLI prints as its next step: pnpm 11 hard-fails install withERR_PNPM_IGNORED_BUILDSon 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 existingtv-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 byonlyBuiltDependencies(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) andunrs-resolver(jest ≥ 30.4's native resolver) above all.@scarf/scarfis 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-pactresolves 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. Runningcheck-pactfrom inside the SDK repo — wheretv-events.snapshot.jsonsits in the repo root — failed withENOENT, and the standing workaround was to pass--snapshot=tv-events.snapshot.jsonby hand. Pre-existing; 1.6.0 left it alone to keep the--producerchange 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-sdkinstall, and vitest running the TypeScript source all land on the right file.tv-events.snapshot.jsonis listed in package.jsonfiles, 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-eventsandsnapshot-eventswere checked for the same defect and do not share it: both take a required positional path, andsnapshot-events' path is a write target, where an upward search would be wrong. They are unchanged.Resolution moved out of
cli.tsintosrc/events/snapshotPath.ts(findDefaultSnapshotPath(),defaultSnapshotCandidates()) so it is unit-testable —cli.tsrunsprocess.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 fornode: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 fromEVENT_CATALOG— tv-api's first-party events. Module-published events are declared inmodule-manifest.jsonunderevents.publishesand never appear in that catalog by design, so a consumer declaring apayloadContracton one tripped rule 1 (EVENT_NOT_PUBLISHED) and failed CI with no correct way out.tv-sdk check-pactgains a repeatable--producer=<manifest>flag that federates a producing module'sevents.publishesinto 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'sevents.publishes[*].payloadContractinto the snapshot shapebuildEventSchemaSnapshot()emits, andmergeProducerSnapshots()unions producer snapshots. Both are pure and takegeneratedAtfrom the caller so regenerated output stays byte-identical.payloadContractis now symmetric. Onevents.subscribes[*]it remains the subset of a producer's payload the consumer reads; onevents.publishes[*]it describes what the module guarantees to emit. Same wire format and no schema change —manifest.schema.jsonandtv-events.snapshot.jsonare byte-identical to 1.5.0.A
publishesentry without apayloadContractstill registers the event, deliberately: the module does publish it, so a subscriber must never be toldEVENT_NOT_PUBLISHED. Such an entry carries an explicitx-tv-opaquemarker (OPAQUE_PAYLOAD_MARKER) and surfaces as a new info-levelPRODUCER_PAYLOAD_OPAQUEdiff, making the coverage gap visible without turning CI red. The marker is explicit rather than inferred from a missingpropertieskey, 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 anyBuildingGraphClientpassed as a prop, queries throughbuildingGraphKeyswith the same query shapes as the modules'useBuildingGraphhooks (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 denormalizedlocationNamelabel 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 anddata-testidcontract (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-queryis imported only by this component;@tv/uiis never imported at all — it resolves only through the authenticated registry and this repo's CI installs without credentials, so the picker takesInput/Labelthrough a structurally-typedcomponentsslot and renders native elements otherwise;react-i18nextstays entirely in the host — every user-facing string arrives via the requiredlabelsprop, 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— nullableBuildingChangeset.buildingId, the newsiteId, and optional provenance columns (externalId,sourceSystem,classifications) onStorey/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 upstreammainbyte-for-byte below its provenance header, and the wire layer derives instead of overriding: thebuildingId/siteIdoverrides onChangesetare gone, andProvenanceFieldsisRequired<Pick<…>>of the vendoredStoreyrather 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/graphexportscreateBuildingGraphClient(): 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-graphpackage (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 aSerialized<T>transform: over JSON, everyDateis an ISO-8601 string. Response-only realities the package does not model — nested includes, pagination envelopes, provenance columns (externalId,sourceSystem,classifications), the nullablebuildingId+siteIdon site-level changesets — are layered on top, verified againstschema.prisma.Why this exists: the fleet survey behind ADR 094 counted six hand-rolled
Spacetypes and two copy-pasteduseBuildingGraph.tshooks (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, andapiPrefix: ''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
exportsmap declared onlyimportandtypeson eleven of its twelve subpaths —.,./manifest,./context,./events,./api,./heartbeat,./react,./testing,./version-check,./permissionsand./i18n. Only./nestjsdeclaredrequire. Each of the eleven now declares it too, resolving to the same file asimport.This failed in the one way that survives every gate. TypeScript resolves a CommonJS import of
./i18nby walking the conditions, finding norequire, and matchingtypesinstead — sotscreports success and the image builds clean. Node has no concept of atypescondition, finds nothing it can load, and throwsERR_PACKAGE_PATH_NOT_EXPORTEDthe 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 onnodenextwith no"type": "module"— so every one of them resolves this package under therequirecondition. None imports./i18nyet, 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:requireandimportpoint at the same ESM file, which Node loads throughrequire(esm). That is what./nestjshas 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 underimportare unaffected.A contract test now asserts that every subpath declares
requireandtypes, and thatrequireandimportagree — the check that would have caught this when./i18nwas added in 1.3.0.
[1.3.0] — 2026-08-09
Added
Locale resolution is now a supported API. New subpath
@tv/extension-sdk/i18nexportsresolveLocale()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'slocalStoragekey, 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 callednavigator.languageitself 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-localemeans the user explicitly picked a language;tv-platform-locale-resolvedmeans this is what the shell is currently rendering, detection included. i18next's defaultcaches: ['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-USresolves to anencatalogue, whilept-BRstayspt-BRrather than collapsing into aptcatalogue nobody ships. A stored language the module has no catalogue for is rejected rather than rendered, becausefallbackLngwould 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/contextgainsreadBuildingSelection(),subscribeBuildingSelection()andisSameBuildingSelection();@tv/extension-sdk/reactgainsuseSelectedBuildingId()anduseBuildingSelection(). 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.buildingwas the documented answer, but it travels through React Context, and React Context does not cross a Module Federation boundary unless@tv/extension-sdkis 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'sbuilding-storagekey and itstv:building-selection-changedevent. 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.organizationis 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 withuseSelectedBuildingId(), states that Building OS has exactly one building selector and modules must not ship a second, and keeps thePlatformContextexample 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
--scopeadds a CLI flag and aScaffoldInputfield. Additive surface bumps the minor.The headline is that
manifest.schema.jsonis usable for the first time. Until now it was emitted in output mode and rejected 30 of the 31 manifests actually shipping, so$schemahad to be left out of module manifests entirely. On this version you can point$schemaat it and get editor validation that agrees withtv-sdk validate.
Fixed
check-exposesno longer reports a false "Missing canonical expose./Shell" on modules that are correct at runtime (#31). It readsvite.config.tsas text, and two static-analysis faults compounded: the entry regex matched only quoted keys, so a computed[`./${EXPOSE_COMPONENT}`]never registered; and theexposes: { … }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-fileconst 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./Shellalongside unresolved keys is a warning, not an error.manifest.schema.jsonis now emitted in input mode. It was generated with Zod's defaultio: 'output', which describes a manifest after parsing — so every field carrying a.default()(sdkVersion,buildingTypes,capabilities,mcpTools,lifecycle, plusui.remoteEntry,ui.dashboardWidgets,ui.defaultLandingByRole,navigation[].roles,mcpTools[].permissions) was published asrequired. The artifact exists so consumers can point$schemaat 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 — includingtv-module-example, the reference implementation — whiletv-sdk validateaccepted all of them. The mismatch went unnoticed because the$schemaURL 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. Newsrc/manifest/generate-schema.test.tslocks the invariant so a future emitter change can't silently reintroduce it.init-module/init-manifestscaffolder defaults corrected. All three were wrong for the external module authors the developer portal now routes to this command:sdkVersionwas 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.$schemawas"../../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
@tvscope, so a third-party module was scaffolded into a scope it must not publish under.
Added
--scope=<scope>oninit-manifestandinit-module(andScaffoldInput.scope), with or without the leading@:--scope=acmeproduces@acme/module-<slug>in both the manifestidand the generatedpackage.jsonname. Defaults to@tvfor first-party use — unchanged behaviour for existing callers.
Changed
init-module's "Next steps" output now sayspnpm 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 newservice-desk.ticket.sla_warning/sla_breached— sotv-sdk validatefailed 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; onlyevents.publishes[].name/events.subscribes[].nameare affected.manifest.schema.jsonregenerated. buildSubject()had the same gap: it threwInvalid eventTypefor snake_case event types, including the catalog's ownbuilding.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.jsonregenerated from tv-api'sPermissionRegistry(#11) — adds thebuilding.modelsandbuilding.paymentssubjects. 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 published1.0.4tarball still carried the pre-regen catalog and installs never saw the new subjects.
[1.0.4] — 2026-07-17
Fixed
tv-sdk write-versionnow reads theTV_APP_COMMITenv var when--commitis not given, before falling back togit rev-parse HEAD. The standard module Dockerfile declaresARG TV_APP_COMMIT+ENV TV_APP_COMMIT=$TV_APP_COMMITexpecting exactly this, but.gitis dockerignored, so every containerized build stamped"commit": "unknown"into version.json. Resolution order is now--commitflag →TV_APP_COMMITenv (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:
@stableexports carry a 6-month deprecation guarantee;@experimentalexports 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@experimentallist below and decide which symbols to track changes on.
Added
- Stability tiers documented: every subpath barrel now carries a
@stableor@experimentalJSDoc tag. See STABILITY.md. @tv/extension-sdk/permissions— permission catalog extractor + types (extractCatalog,extractCatalogFromFile,PermissionCatalog,SubjectEntry). Phase 2.4.tv-sdk snapshot-permissionsCLI command — extracts the catalog from tv-api'sPERMISSION_ROLE_MAPsource. Phase 2.4.permissions.snapshot.jsonartifact ships with the package (17 subjects, 33 actions as of 2026-05-25).@tv/extension-sdk/sandbox—tv-sdk sandboxCLI 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-inz.toJSONSchema(). Affectsmanifest.schema.json+tv-events.snapshot.jsonoutputs — same contract, normalized representation. CI'scheck-eventsself-consistency passes against the regenerated snapshot. tsconfig.jsonadds"types": ["node"]sonode: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.jsonregenerated under Zod 4's emitter — more strict (more required fields surfaced). Downstream consumers using$schemafor editor validation should re-validate their manifests.
Removed
zod-to-json-schemadependency (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—PlatformContexttypes@tv/extension-sdk/events— catalog, envelope, subjects@tv/extension-sdk/api—PlatformApiClienttypes@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.