- JavaScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| bin | ||
| compatibility | ||
| contracts | ||
| docs | ||
| fixtures | ||
| generated | ||
| governance | ||
| lib | ||
| manifests | ||
| scripts | ||
| test | ||
| .gitignore | ||
| .gitleaks.toml | ||
| .gitleaksignore | ||
| CHANGELOG.md | ||
| index.js | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| STATUS.json | ||
OpenVibe.Contracts
Machine-readable contracts for the whole OpenVibe network.
Status: alpha (first released in Wave 1; the release is package.json's version, tagged). At v0.210.0: 798 schemas in contracts/catalog.json, 45 service manifests, 348 capability manifests, 13 namespace manifests and the staff capability map. Service repositories run openvibe-contracts-check and the pin-drift check in CI, and deployed services verify service and app tokens with serviceAuth in production. Consumers pin release tags (each repository's STATUS.json names its pin). node scripts/docs-currency.js <root> checks every checkout's README and STATUS.json against its code: the pins and versions they name, a library's consumer list, the deployed flag against the manifest, README links, and the paths and migrations they name. node scripts/estate.js generates docs/ESTATE.md and manifests/repositories/ from the estate checkouts, and --check fails on drift (plan T1 step 3).
Plan: OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
License: AGPL-3.0 (same as every OpenVibe service).
Using it
Pin a release tag. It's a public repo, so no deploy key is needed:
"dependencies": { "openvibe-contracts": "github:OpenVibers/OpenVibe.Contracts#v0.1.0" }
const contracts = require('openvibe-contracts');
contracts.validate('identity.subject-ref@1', { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }); // { valid, errors }
contracts.ids.newId('user'); // 'usr_01J…' (ULID, time-sortable)
contracts.ids.parseSubject('service:live'); // { type: 'service', id: 'live' }
app.use(contracts.http.middleware()); // req.ov = { traceId, requestId, traceparent, idempotencyKey }
contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'not granted', ctx: req.ov });
contracts.capabilities.check(tokenClaims, 'media.object.upload', { namespace: 'live.files' });
// { allowed: false, code: 'capability.denied' | 'capability.namespace_denied' | 'capability.unknown', reason }
Service principals (v0.2): a caller gets a short-lived token from Network with its OAuth client credentials, and a receiver guards a route with the capability it performs:
const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl: `${NETWORK}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network' });
await fetch(url, { headers: await tokens.authHeaders() });
router.post('/coins/credit', contracts.serviceAuth.requireCapability('network.coins.credit', {
publicKey, issuer, audience: 'openvibe.network',
legacy: (req) => req.headers['x-internal-key'] === key, // old callers keep working during migration
}), handler);
A request that presents a Bearer token is judged only on that token; a bad token is never rescued by a legacy header.
Contract check for a service's CI (v0.3): npx openvibe-contracts-check --service network --src server. It fails if the installed contracts are outside the service manifest's contractRanges, if the code enforces a capability (requireCapability('…'), guard('…'), capabilities.check(…, '…')) that is undefined, retired or owned by another service, or if it validates against a contract id that doesn't exist.
TypeScript types: generated/typescript/index.d.ts ("types" in package.json). All schemas in one file: generated/json-schema/bundle.json.
OpenAPI 3.1 per service: generated/openapi/<service>.json (index in generated/openapi/index.json; require('openvibe-contracts').openapi.document('tools')). Each is built from the capabilities (implementedBy routes, input and output schemas) by lib/openapi.js. It lists every route a capability names, with the capabilities it performs, its request and response schemas and problem+json errors. OpenVibe.Codes serves them together as the API explorer.
What's in v0.1
| Contract | Owner | Purpose |
|---|---|---|
identity.subject-ref |
network | { type: user|guest|service|app|mod|system, id }: the one actor reference; never a service-local integer |
identity.legacy-identity-map |
network | row shape of identity_legacy_map: legacy id to subject, kept alongside legacy ids |
identity.service-token-claims |
network | client-credentials token for service/app/mod principals (replaces X-Internal-Key) |
common.entity-ref |
contracts | { service, type, id, revision?, label? }: typed cross-service reference instead of a foreign key |
errors.problem |
contracts | RFC 9457 problem details plus stable code, request_id, trace_id and a compatibility error field |
registry.service-manifest |
network | what a service is, where it lives and what it offers |
capabilities.capability |
network | an invokable action: permissions, resource constraints, quota class, events |
events.event-envelope |
events | durable event shape for OpenVibe.Events (Wave 3) |
media.media-ref |
media | med_<ULID> (Wave 4) or transitional legacy:<app>:<kind>:<id> |
Manifests: manifests/services/ (45 services at v0.210.0: 39 alpha, 1 beta (Media), 4 stable (Games, Live, Network, Tools) and 1 retired (Sites, 2026-10-14); OpenVibe.Realtime has no manifest, as ADR-005 closed it before it was built; Contracts, SDK, Shared and Publishing are released libraries and Examples a repository with CI, none of them a runtime; docs/ESTATE.md tables every repository), manifests/capabilities/ (348 capabilities at v0.210.0: 306 active, 27 planned and 15 retired; the v0.1 set was Media upload/read, chat send, paste create, coins credit/debit, notifications push, subject resolve and Community post). Each active capability names the route that implements it today. Where a service is reachable today (public, loopback only, library) is Network's observed overlay (server/registry/exposure.js there); manifest notes say whether a service is deployed and launched publicly.
Ids. Subjects use prefixed ULIDs: usr_, gst_, app_, mod_. Services and system actors use slugs (live, media). Events use evt_ and Media objects med_.
User modules (v0.4)
manifests/products/<domain>.json (registry.product@1) is the product catalog the OpenVibe.Sites repository used to hold (plan T11 lane D): one file per product domain that no service manifest presents as a site (planned products, a service's sub-domains and extra brand domains, closed or moved addresses), with its tld, accent, description, pillars, FAQ, keywords, vision, launch requirement and relationships. A service manifest whose site carries tld (registry.service-manifest 1.1.0) is the catalog home of its own domain instead. contracts.products.catalog() lists both homes; every domain has exactly one (test/product-catalog.test.js, against the 33-domain Sites snapshot in test/fixtures/sites-domains.json).
manifests/namespaces/*.json define per-subject module namespaces (modules.namespace@1): owner, data schema, writers (owner service and/or the user), public fields, quota and what happens when the owner is retired. OpenVibe.Network stores the records (modules.module-record@1, revision-checked writes). contracts.modules.validateData / publicView / canWrite apply the same rules everywhere. Modules hold portable preferences and summaries, never domain truth, money or authoritative game inventory.
Every change to a record is announced as network.module.updated (v0.32): the revision after the change, the changed field names and only the new values of changed public fields. Removing an account deletes its records; merging two keeps the survivor's record where both have one and moves the rest. onOwnerRemoved is about the owning service being retired, not the person.
Developer-app events (v0.28)
Three public capabilities let a developer app (ADR-014) use OpenVibe.Events with a scoped app token, without any first-party events.* capability (those stay internal):
| Capability | What the app may do |
|---|---|
events.app.publish |
publish event types app.<project_key>.<name>[.<more>] only |
events.app.read |
pull its own project's events (same environment) plus first-party public events |
events.app.subscribe |
webhook subscriptions in the same scope, to public https endpoints only |
project_key is p followed by the project's ULID in lowercase (prj_01JAB… → p01jab…), so it fits an event-type segment. An app event's source is app- followed by the app's ULID in lowercase (app:app_01JAB… → app-01jab…), which keeps events.event-envelope@1 unchanged (its source pattern already allows it). OpenVibe.Events enforces the scope, the sandbox separation and per-project quotas.
Event payload contracts (v0.30)
An event type's payload contract is named after the type. The envelope's version is the contract's major, and the file is contracts/events/payloads/<event_type>.v<version>.json. The owner is the producing service, whose manifest lists the type in eventsProduced. Contracts with status planned describe events the owner does not emit yet (ai.run.*). tools.job.* are active since v0.33.0, because Tools publishes them. v0.34.0 added News, Reviews, Coupons, Deals, Trade, Games and media.object.deleted|visibility_changed (51, all emitted today). Listed events still without a contract: chat.message.created, chat.dm.created, chat.moderation.action, codes.app.* (3), host.deploy.* and host.domain.verified (4), media.vod.*, media.clip.*, media.object.uploaded, media.storage.* (7), network.app.*, network.credential.*, network.grant.changed (5) and openre.* (7).
if (env.payload.redacted === true) return; // a tombstone (events.tombstone-payload@1)
const r = contracts.validate(`${env.event_type}@${env.version}`, env.payload);
Redaction (ADR-026). Any event may carry payload.redacts (events.redaction-directive@1), shaped { event_ids?, subject_type?, subject_ids? } with up to 1000 ids each, to take back the producer's own earlier events. OpenVibe.Events rewrites each target into a tombstone at its original seq: { redacted: true, redacted_at, redacted_by }, with the producer itself as actor. Naming another source's event refuses the whole batch with 403 events.redaction_not_allowed. A malformed directive gets 422 events.invalid_redaction. chat.message.deleted always carries one. Browsers are replayed only the public events of the last REALTIME_PUBLIC_REPLAY_SECONDS (default 300); an older cursor gets event: gap with reason public_window.
Project usage rollups (v0.63)
The services that own a developer capability count each project's use of it and report one rollup per closed hour, never one event per request (roadmap WS-N task 4). Every <service>.usage.recorded event carries common.usage-recorded@1: project, environment, capability, optional dimension (job type, tool), unit, window, quantity, errors, errors by code and up to ten sampled failures with their trace ids. Emitted today: tools.usage.recorded (jobs) and events.usage.recorded (published events, webhook deliveries). OpenVibe.Network adds them up per project and day and answers GET /api/v1/projects/:project/usage (network.project-usage-result@1) to the project's owner and admins; OpenVibe.Codes shows it as the project's usage page. A rollup is totals, not a delta: the latest one for a key replaces the earlier, so a producer may re-send a window safely.
Event delivery signatures
OpenVibe.Events POSTs each webhook delivery as { "event": <events.event-envelope@1>, "seq": n }, keyed with the subscription secret:
| Header | Value |
|---|---|
X-OpenVibe-Signature (v1) |
sha256=<hex HMAC-SHA256 of the raw body> |
X-OpenVibe-Timestamp |
<unix seconds> when this attempt was sent (every retry gets a fresh one) |
X-OpenVibe-Signature-V2 |
t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>"> |
v1 covers only the body, so a captured delivery verifies forever (only event_id dedupe limits a replay). A consumer checking v2 refuses a timestamp more than 300 s from its own clock in either direction, compares in constant time, and never falls back to v1 when a v2 header is present but wrong or stale. openvibe-sdk ≥ 0.4.0 does this in parseDelivery(raw, headers, secret, { requireV2 }) and verifyDeliveryV2(); the openvibe-events package has verifyDeliveryV2().
Rollout: (1) Events sends v2 beside v1; (2) each consumer moves to SDK 0.4.0, which verifies v2 whenever it is present; (3) each consumer sets requireV2: true, so a v1-only (header-stripped) replay fails; (4) once every consumer requires v2, v1 may be dropped. See ADR-004.
Channel/owner lineage (v0.32)
One resolver answers "which channel, and whose, is this?" for channel, stream, VOD, clip, Pulse and creator-UI callers (roadmap §15.10, D20). OpenVibe.Live implements it at GET|POST /internal/lineage/resolve (capability live.lineage.resolve, loopback). The request is lineage.resolve-request@1, the answer lineage.resolution@1.
| Precedence | Input | Rule | Confidence |
|---|---|---|---|
| 1 | slug |
explicit_slug |
exact |
| 2 | slug <channel>/<slot>, parent_slug |
nested_slug |
exact |
| 3 | channel_id |
channel_id |
exact |
| 4 | stream_id, slot_id |
stream_lookup |
exact |
| 5 | clip_id, vod_id |
the record's lineage: its stream, its parent VOD (vod_parent), the owner recorded on it (legacy_metadata) |
derived |
| 6 | media_object_id |
media_lineage (legacy_map for a legacy: reference) |
derived / legacy_map |
| 7 | owner_subject |
owner_subject |
exact |
| 8 | legacy_ids |
legacy_map |
legacy_map |
The first input that resolves decides. Every other input that resolves must name the same channel, or the answer is unresolved with reason conflict. A clip belongs to the clipped channel, never to the clipper. A display name never resolves anything: a request holding only one is answered unresolved, display_name_only.
Staff capability map (v0.34)
Staff roles and what each may do are one contract, policy.staff-role-map@1, and one map,
manifests/policy/staff-roles.json (ADR-022). OpenVibe.Network owns the roles and issues the
capabilities as claims in its user tokens, and every service checks a staff.* capability instead of
comparing role names:
contracts.staff.can(req.user, 'staff.moderation.bans'); // claims { role, is_owner, staff_caps? }
contracts.staff.capabilitiesOf('global_mod'); // what a role holds
- Roles, lowest first:
user < streamer < global_mod < admin < owner. A role holds everything below it.userandstreamerhold no staff capability. owneris not a stored role. Tokens carryrole: adminplusis_owner: true, and a role claim ofownercounts asuser.- Secrets, money, loyalty grants and making admins are the owner's alone.
- Until Network issues
staff_caps,can()derives them from the role, so the answer is the same before and after. - Channel powers stay with the product and are not staff capabilities: channel owner, channel
moderators, stream owner and call creator are listed under
local. - Each capability lists the role checks it replaces (
gates, per service, file:line), so adoption in Live, Chat and Community is a mechanical swap. - Staff capabilities never appear in service, app or mod tokens, and are never delegated (ADR-003 amendment).
Tools platform API (v0.33)
Every tool on openvibe.tools gets a descriptor, tools.tool@1 (ADR-027). The page, the run API,
openvibe-sdk and the docs all read it. It says:
- where the tool runs (
execution:client|sync|job); - whether the API exposes it (
api), and how to call it (run); - its input schema, files, output, limits and auth;
- its quota class and cost;
- whether it fetches hosts the caller chose (
egress); - optionally (v0.33.1), its catalogue
keywords, which?q=searches, andexamplesof runs for the docs and the OpenAPI document.
| Route (openvibe.tools) | Capability | Contract |
|---|---|---|
GET /api/v1/tools, /api/v1/tools/:id, /api/v1/tools/:id/schema |
tools.tool.read (public) |
tools.tool-list@1, tools.tool@1 |
POST /api/v1/tools/:id/run |
tools.tool.run (public); probes need tools.net.probe (partner) |
tools.run-request@1 → tools.run@1 |
/api/v1/jobs… (img, audio, docs; the gateway later) |
tools.job.create, .read, .cancel |
tools.job-request@1 → tools.job@1 |
A run is answered inline, or with its job (202 + Location) while a job tool is still working.
Callers are tiered: anonymous < session < user < app/service. Quotas count each tool's cost within
its quotaClass. yt is page-only (api: false). Only real tools have descriptors: there is no
planned status, and a planned or mirror id is 404 tools.tool.not_found. The registry routes are
served on the gateway and every satellite (tools.tool.read is active since v0.33.1), and so is the run route
(tools.tool.run and tools.net.probe are active).
contracts.tools.checkDescriptor(d) and checkList(list) apply the rules that JSON Schema cannot
express, such as run.path being the tool's own, and check each example's input against an embedded
input schema. jobInput(d, input) builds a job tool's job input.
contracts.zones.checkObjectZone(z), checkObjectZoneList(list) and checkObjectZoneUsage(u) add the
cross-field rules of the planned object zones (ADR-031 amendment 2026-10-02): ovrn and media_namespace
name the zone's own project and id, a name belongs to one zone of its environment for ever, and a usage
answer's ovrn names its zone_id. contracts.usage.checkUsageRecorded(u) refuses a usage rollup whose
resource names another project than its project_id.
contracts.resources holds the resource names and the control-operation contract of ADR-048 (plan T13):
parse(ovrn) and format({ service, project_id, type, id }) are the one OVRN parser and formatter, nameOf(summary)
composes the name of a common.resource-summary@1, checkControlRequest(q) refuses a request whose resource
names another project than its project_id (and create names resource_kind, every other action a resource),
and checkControlResult(r) holds problem and confirmation_required to the result's state.
Tools' problem codes are listed with the routes that answer them (tools.run-request@1,
tools.job-request@1, tools.tool-list@1, tools.tool@1). They include 400 tools.query.invalid,
404 tools.tool.not_found, 413 tools.pdf.too_many_pages, 422 tools.pdf.wrong_password,
503 tools.unavailable (a program or upstream a tool needs is missing), and, with Retry-After,
429 tools.quota.exceeded and 503 tools.busy.
Lifecycle declarations (v0.55)
Each service manifest has a lifecycle block, filled from the service's code and systemd units (roadmap WS-P task 1). A part that does not apply is { "none": "<why>" }.
| Field | What it says |
|---|---|
liveness |
endpoint (the manifest's health) and means: what a 200 proves. Readiness stays ready. |
shutdown |
signal, deadlineSeconds (signal to exit, as the process's own forced-exit timer bounds it), drains in order, optional workers (worker units that drain on their own) and note |
startupRecovery |
resumes: what the next start picks up, each { kind: outbox|jobs|sessions|consumer|schedule|state, what } |
rollback |
conditions (automatic and manual), window, blockers (forward-only migrations, table rebuilds, authority switches) |
contracts |
range: the openvibe-contracts versions accepted, equal to contractRanges['openvibe-contracts'] |
leases |
claims: { what, holder, expires, fencing } for each thing claimed so that one owner acts at a time |
ovhost validate <service> (OpenVibe.Host) reads the block from the inventory or from this package's manifest. It fails when a field is missing, when deadlineSeconds exceeds the unit's TimeoutStopSec or the unit's KillSignal is a different signal, and when the checkout's installed openvibe-contracts is outside contracts.range.
Run job API (v0.90)
OpenVibe.Run (manifests/services/run.json; plan T14 R1, deployed since 2026-10-07 and public at https://openvibe.run)
takes jobs, places each on a node that advertises worker:<class> and sends it as platform.job@1. Its routes, under
https://openvibe.run/api/v1:
| Route | Capability | Body | Answer |
|---|---|---|---|
POST /api/v1/jobs |
run.job.submit |
run.job-create-request@1 |
run.job-create-result@1 (201, or 200 for an idempotent repeat) |
GET /api/v1/jobs |
run.job.list |
run.job-list-query@1 (query string) |
run.job-list-result@1 (cursor pages) |
GET /api/v1/jobs/:id |
run.job.read |
— | run.job-read-result@1 |
POST /api/v1/jobs/:id/cancel |
run.job.cancel |
— | run.job-read-result@1 |
POST /api/v1/jobs/:id/stream/ticket |
run.job.stream |
— | run.job-stream-ticket-result@1 (one-use, 120 s) |
GET /api/v1/jobs/:id/stream?ticket= |
run.job.stream |
— | SSE of run.job-stream-event@1 (output, state, end; Last-Event-ID resumes) |
GET /api/v1/admin/jobs |
run.job.admin (internal) |
run.job-list-query@1 with project_id |
run.job-list-result@1 |
A job moves queued → placed → running and ends succeeded, failed, cancelled or expired; Run publishes
run.job.queued|started|succeeded|failed|cancelled|expired. Run mints the job id (job_<ULID>, ids.newId('job'))
and passes it unchanged as the platform.job@1 id, so every metering reading is run:<job id>:<n> (service run,
operation function.invoke, unit s) and usage.seconds is their sum. Inputs are Media objects pinned by sha256
(never a URL); egress takes Node's worker.egress values (none by default, public, openvibe-only).
Versioning and compatibility
- A contract id is permanent. Minor versions only add optional fields.
- A breaking change is a new file (
<name>.v2.json) and catalog entry. The old one is listed incompatibility/deprecations.jsonwith a replacement and aremoveAfterdate. scripts/compat.jsruns innpm testand CI. It fails on removed or retyped properties, newly required fields, removed enum values, tightenedadditionalProperties, or catalog removals without a deprecation, compared against the previous release tag.generated/is checked in;npm testfails if it is stale (npm run generate).- Every contract ships valid and invalid fixtures under
fixtures/<id>/.
Purpose
The canonical, versioned source of every cross-service contract: subject identity, error envelope, service manifests, capability schemas, event envelopes and topics, user-module schemas, media/chat/community/billing/AI/games/jobs/release schemas, plus generated TypeScript types, JSON Schema and OpenAPI fragments. Everything else in the network migrates contract-by-contract instead of through copied prose.
Owns
- contract IDs, semantic versions, owners, visibility and deprecation state
SubjectRef(user|guest|service|app|mod|system+ stable id) and the legacy identity map shape- request/error envelope:
traceparent,X-OpenVibe-Request-Id,Idempotency-Key, RFC 9457 problem details, resourcerevision/ETag - capability contract (
id,owner, input/output schema, permissions, resource constraints, quota class, events) - service manifests, namespace and topic manifests, compatibility aliases and deprecations
Does not own
- any runtime process, database or hosted API (the registry API lives in OpenVibe.Network)
- product business logic
Planned surfaces
contracts/<domain>/…schemas with fixtures and examplesmanifests/{services,namespaces,capabilities,topics}generated/{typescript,json-schema,openapi}published as versioned packagescompatibility/aliases.json,compatibility/deprecations.json
Data (authority tables / families)
- none (artifact repository)
Capabilities and events
Contracts implements no capability and produces or consumes no event (its service manifest lists
none). It defines them: every capability manifest in manifests/capabilities/ names its owner, and
capabilities.check() / serviceAuth.requireCapability() are how the other services enforce them.
It calls no service. openvibe-contracts-check is the contract validation CI every service runs.
Depends on
- OpenVibe.Network (issues subject IDs and service principals)
Acceptance (must be true before "done")
- every contract carries id, version, owner, schema, visibility, compatibility policy, deprecation state, examples and generated types
- a deprecated contract advertises its replacement and compatibility window
- N/N-1 compatibility fixtures pass across core services
Bootstrap / extraction source
Derived from the implicit contracts already in the eleven current repositories (workspace CONTRACTS.md, Network prose contracts, Media/Live/Community route shapes). First deliverables: subject, errors, service manifest, capability schema, event envelope, media object IDs, service-token claims.
Security
Reporting a vulnerability: SECURITY.md. Contracts has no runtime, but its code runs in every service, so:
serviceAuthjudges a request that presents a Bearer token on that token alone (a bad token is never rescued by a legacy header), verifies RS256 signatures, issuer, audience and expiry, and grants a capability only by exact id or afamily.*grant (capabilities.check()).- The staff capability map (
manifests/policy/staff-roles.json) is the one source for staff gates. - Schemas carry no secrets and no personal data; fixtures use fake values (gitleaks scans history in CI).
- Dependencies are
ajvandajv-formatsonly.
Deploy
Nothing is deployed from this repository: there is no unit, port or env file. A release is a git tag
(vX.Y.Z, after node scripts/compat.js <latest tag> passes); services pin the tag's tarball in
package.json and pick it up with their next deploy (sudo ovhost deploy <service>). Network serves the
registry (/api/v1/registry/services) from the tag it pins. A bad release is rolled back by
consumers pinning the previous tag; a published tag is never moved.
Launch rule
This repository does not make a product real: a product domain stays an entry in manifests/products/<domain>.json
(OpenVibe.Sites, which served the placeholder pages, retired on 2026-10-14) until all of the following exist in the
product's own repository (plan §12.12):
- an owning runtime with health/readiness endpoints and observability;
- canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
- server-rendered or static public routes that are useful without JavaScript;
- real persistence and end-to-end workflows;
- capability and event registration against
OpenVibe.Contracts; - a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
- acceptance tests proving the advertised functionality.
The launch release moves the domain's catalog entry from manifests/products/<domain>.json to its
service manifest's site (registry.service-manifest 1.1.0, without launch), switches routing and
registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
implemented service.
Part of the OpenVibe network. Built in the open by OpenVibers.
Release note: v0.27.0 and v0.28.0
The v0.27.0 tag (commit 742db68) was cut on top of the 0.28.0 developer-app events commit, so its
package reports version 0.28.0 and contains both changes (Codes: codes.app-manifest@1,
codes.release.*; Events: events.app.*). v0.28.0 points at the same commit. Pin v0.28.0; v0.27.0
stays for consumers that already pinned it and is identical.