Draft · subject to change
Portable review metadata on git-meta
Status: draft v0. Breaking changes are expected.
Namespace: shitposter, provisional; it
will change before v1. Builds on: git-meta specification v0.1.
This document specifies how code review data (reviews, review decisions, discussion threads and comments) is stored as git-meta metadata, so that any tool can write and read it and the data travels with ordinary Git transport.
It is an application schema defined by this project.
It is not part of the git-meta specification and uses no git-meta
extension. Where this document says “git-meta”, it refers to behavior
git-meta itself defines; everything else is a convention of this
specification. The reference implementation is the
review-meta-schema crate of the shitpost tool.
The key words “must”, “must not”, “should” and “may” are to be read as in RFC 2119.
1. Overview
All data lives on the git-meta project target. A review
and everything belonging to it is stored under one key prefix:
shitposter:schema-version
shitposter:review:<review-id>:…
…:decision:<decision-id>:…
…:thread:<thread-id>:…
…:comment:<comment-id>:…
Every field is a separate git-meta key, so independent fields merge
independently. All values are git-meta strings, except each
thread’s ordered comment list, which is a git-meta
list.
Serialized by git-meta, this becomes ordinary tree paths, for example:
project/shitposter/schema-version/__value
project/shitposter/review/<review-id>/title/__value
project/shitposter/review/<review-id>/thread/<thread-id>/comments/__list/<timestamp>-<hash>
2. Versioning
Writers must set shitposter:schema-version to the
version of this document they implement (0 for this draft).
Readers must refuse data whose version they do not support, rather than
guess, even if no reviews are present. Writers must not write into a
store whose marker names a different version: doing so would relabel
that data without migrating it. A store without a marker must not
contain reviews.
3. Identifiers
Every review, decision, thread and comment has a portable ID: a UUID, written in canonical lowercase hyphenated form in keys and values. Readers must reject any other form (uppercase, braces, no hyphens, URN), because IDs are part of keys and an alias would address the same object under a second key.
3.1 Imported objects
An object imported from a provider (forge) for the first time gets a UUIDv5:
uuid5(NAMESPACE, "<provider-host>/<kind>/<provider-object-id>")
NAMESPACE = d4d12fe2-0c4d-54a9-851f-455f71cec4bf
= uuid5(NAMESPACE_URL, "https://shit-poster.dev/ns/review-model")
| Object | <kind> |
|---|---|
| review | review |
| decision | review-decision |
| thread | review-thread |
| comment | review-comment |
Example: the GitHub thread PRRT_kwDOAAAB on
github.com has ID
b784df81-3649-5fcc-b841-4dc06f6d1944.
- The provider host is included because separate deployments of the same forge can issue colliding IDs.
- The repository is not included. Provider object IDs are unique per host, and leaving the repository out lets forks and independent importers derive the same IDs, so their metadata merges without duplicates.
3.2 IDs are not re-derived
An ID is derived once, on first import from the forge where the object was created, and then stored. When importing, a writer must first look for an existing object mapped to the provider object and reuse its ID; it may derive a new ID only if none exists. Otherwise an object created locally, or first seen on another forge, would be duplicated when it comes back with a new provider ID.
3.3 Locally created objects
Objects created outside a provider, for example a comment written with a local tool, should get random UUIDv4 IDs. They have no provider reference until they are published to a provider, and their authors may have no provider account (§5.1). v0 tools do not create such objects yet, but readers must accept them.
4. Value encoding
| Kind | Encoding |
|---|---|
| timestamp | RFC 3339 in UTC, e.g. 2026-10-07T16:36:44Z |
| commit | full object ID in lowercase hex: 40 digits (SHA-1) or 64 (SHA-256); never abbreviated |
| boolean | true or false |
| integer | decimal digits |
| line range | <side>:<line>-<side>:<line>,
sides old or new, lines 1-based and inclusive,
e.g. old:5-new:8 |
| text | the text itself; may be empty |
Enumerations are listed with their fields below and are written exactly as shown.
Each value has exactly one valid encoding. Readers must reject anything else, such as a timestamp with a non-UTC offset, an integer with a sign or leading zeros, an abbreviated commit, or an uppercase UUID.
5. Keys
In the tables, <P> is the key prefix of the object
being described. A field marked optional is absent when the
value does not exist (see §6.2).
5.1 Shared groups
Provider reference, where the object came from
(provenance, never identity). The group is optional as a whole:
it is absent for an object created locally and not yet published to a
provider. When present, provider:host and
provider:id are both required.
| Key | Value |
|---|---|
<P>:provider:host |
provider deployment, e.g. github.com |
<P>:provider:id |
the provider’s own object ID |
<P>:provider:url |
web URL; optional |
Author. author:name and
author:email are always required. The three
author:provider-* keys describe the author’s provider
account; they are optional as a group: all three are present,
or none is, for example for someone writing a review locally without a
provider account.
| Key | Value |
|---|---|
<P>:author:name |
display name |
<P>:author:email |
email address only, e.g. alice@example.com; with
author:name it forms the git-style identity (§7) |
<P>:author:provider-host |
provider deployment of the account |
<P>:author:provider-user-id |
stable provider account ID |
<P>:author:provider-login |
provider login at import time |
5.2 Review
<P> =
shitposter:review:<review-id>. A review is a proposed
change under review, such as a GitHub pull request or GitLab merge
request.
| Key | Value |
|---|---|
| provider group | |
| author group | who opened the review |
<P>:number |
integer shown to users (#123) |
<P>:title |
text |
<P>:description |
text |
<P>:state |
open, closed or merged |
<P>:created-at |
timestamp |
<P>:edited-at |
timestamp; optional, present only if title or description was edited |
<P>:head:sha,
<P>:head:ref |
head commit SHA and branch name at import |
<P>:base:sha,
<P>:base:ref |
base commit SHA and branch name at import |
5.3 Decision
<P> =
shitposter:review:<review-id>:decision:<decision-id>.
A reviewer’s verdict on one exact commit. It must never be applied to a
different (e.g. rewritten) commit.
| Key | Value |
|---|---|
| provider group, author group | |
<P>:verdict |
approved, changes-requested or
commented |
<P>:body |
text submitted with the verdict |
<P>:commit |
full SHA the verdict applies to |
<P>:submitted-at |
timestamp |
<P>:withdrawn-at |
timestamp; optional, present once the verdict no longer counts (e.g. dismissed, revoked, reset on push) |
<P>:withdrawn-message |
text; optional, reason for the withdrawal |
A withdrawn decision keeps its original verdict. Withdrawal records an explicit event; a verdict that is merely superseded by the same reviewer’s later verdict is not withdrawn (see §8).
5.4 Thread
<P> =
shitposter:review:<review-id>:thread:<thread-id>.
A discussion attached to a location in the change.
| Key | Value |
|---|---|
| provider group | |
<P>:resolved |
boolean |
<P>:path |
file path in the repository |
<P>:original:commit |
full SHA the thread was written against |
<P>:original:lines |
line range; optional, absent for a comment on the whole file |
<P>:original:hunk |
the unified-diff hunk of the original placement (§5.4.1); optional, absent for a comment on the whole file |
<P>:current:commit |
full SHA the thread is currently placed on; optional |
<P>:current:lines |
line range on that commit; optional |
<P>:comments |
git-meta list of comment IDs in conversation order
(§6.3) |
The original placement is preserved exactly as reported. The current
placement is where the provider (or, later, anchor reconciliation)
places the thread on the review head. If current:commit is
absent, the code the thread refers to no longer exists on the head;
providers call this “outdated”. It is a property of the placement, not
separate state.
5.4.1 Original hunk
original:hunk holds the diff the comment was written on,
as unified-diff text: an
@@ -<old>[,<count>] +<new>[,<count>] @@
header followed by the hunk’s lines, each prefixed with
(unchanged), - (removed) or + (added), up to
and including the last line of original:lines.
The old and new line numbers of
original:lines refer to the two sides of this hunk. This
keeps the placement interpretable without any commit: in particular, an
old line number cannot be resolved from
original:commit alone, because the commit on the old side
of the diff is not recorded (GitHub does not expose it).
Writers store the hunk as the provider reported it. Readers must
reject a hunk that does not parse, that is present without
original:lines, or whose last line is not the end of
original:lines (on its side).
5.5 Comment
<P> =
shitposter:review:<review-id>:comment:<comment-id>.
Comments are stored per review, not per thread; a thread refers to its
comments through its comments list.
| Key | Value |
|---|---|
| provider group, author group | |
<P>:body |
text, current version |
<P>:created-at |
timestamp |
<P>:edited-at |
timestamp; optional, present only if the body was edited |
Edit history is not preserved; only the current body and the time of the last edit are.
6. Writing
6.1 Whole-object writes
A writer importing an object must write all of its fields, not only the ones it believes changed. Writing the same data again must not change any value, so repeated imports produce no new git-meta commits.
git-meta resolves concurrent string writes in favor of the last pusher, so a writer that imported an older provider state can overwrite a newer import when it pushes. Writers that import from a provider and push to a shared metadata remote should therefore pull before importing, and, when a push is rejected because the remote moved on, import the provider’s current state again after merging and before pushing again.
This protects only the reviews being imported. A push also publishes every other review the writer changed locally and has not pushed, and those local values win over newer ones on the remote in the same way. Writers should therefore not push while another review that the remote already has differs locally, unless they import that review from the provider again as well.
6.2 Absent fields
A writer must remove (using git-meta removal, which records a
tombstone) any optional field whose value does not exist, for
example current:commit and current:lines once
a thread becomes outdated. git-meta does not treat missing paths as
deletion, so leaving the old value would keep it visible.
6.3 Comment lists
A thread’s comments list must be written as a whole,
replacing the previous list, never by appending. git-meta list entries
are ordered by an entry ID derived from a millisecond timestamp and a
hash of the value. Each entry’s timestamp must be the comment’s creation
time in milliseconds since the Unix epoch, raised where necessary to be
strictly greater than the previous entry’s, so that a reply created in
the same millisecond as its parent keeps its place. Appending would
stamp entries with the writer’s clock and duplicate them on every
re-import.
A list must not contain the same comment ID twice, and comments
created before 1970-01-01T00:00:00Z cannot be written: git-meta reads an
entry ID up to its first -, so negative timestamps are not
representable. Readers must reject lists with duplicate IDs.
6.4 Unpublished content
Writers must import only content that other participants can see on the provider. In particular, unsubmitted draft comments or reviews, which a provider may show to their own author, must never be written.
6.5 Removed objects
Removing a review, decision, thread or comment is not specified in v0. Writers must not remove objects that no longer appear in provider data.
7. Identities
author:name and author:email form a
git-style identity (Name <email>) intended for
display. If the provider exposes no email, writers should use the
provider’s no-reply form when one exists; on GitHub that is
<user-id>+<login>@users.noreply.github.com
(<user-id>+<login>[bot]@… for bots), which is
also what GitHub writes into commits for such users.
An identity is not proof of who someone is. Readers must not conclude that two accounts on different providers belong to the same person because names or emails match; such links require an explicit mapping, which v0 does not define.
An author without a provider account, for example someone writing a
review locally, is described by author:name and
author:email alone (§5.1). That identity is self-declared
and unverified (§9).
The git-meta authorship recorded for each key (who wrote the
metadata) is a different thing from author:* (who wrote the
comment). For imports, the former is the importer.
8. Reading
Readers validate each review on its own. A review whose data violates this specification is rejected and should be reported, but it must not prevent other reviews from being read; only problems concerning the whole store, such as an unsupported schema version (§2), apply to every review.
Readers should present:
- decisions ordered by
submitted-at, then by ID; - threads ordered by the
created-atof their first comment, then by ID; - comments in
commentslist order.
A reviewer’s effective verdict is their latest
decision, by submitted-at, with verdict
approved or changes-requested;
commented decisions do not change it. If that decision is
withdrawn, the reviewer has no effective verdict: a withdrawal does not
revive an earlier verdict the reviewer had already superseded. For
example, a change request followed by an approval from the same reviewer
leaves the approval effective; if the approval is later dismissed,
neither counts. Both decisions remain part of the history. Reviewers are
identified by provider account (author:provider-host and
author:provider-user-id). A decision without a provider
account identifies its reviewer by the exact git-style identity
(author:name and author:email) instead. Such
an identity is self-declared, so readers should mark these verdicts as
unverified, and must never treat a reviewer with an account and one
without as the same person, even if names or emails match. An effective
verdict still applies only to its commit; when that is not
the review’s current head, readers should say so rather than present the
verdict as covering the head.
9. Open questions
- Namespace.
shitposteris provisional; the final namespace will be fixed before v1. - git-meta standard keys. git-meta recommends
review:reviewedandreview:approvedsets on commit targets andreview:urlon branch targets. v0 does not write them yet; a later version should, with withdrawn approvals excluded. - Deletion of objects (§6.5) and edit history (§5.5).
- Locally created objects and outbound sync, including the reverse index from provider IDs to portable IDs required by §3.2.
- Verifying local authors. Without a provider
account,
author:*is whatever the writer claimed. Before verdicts written locally can count anywhere, the schema needs a way to authenticate their authors, such as signed metadata. - Scale. Very large repositories may need reviews
spread across targets other than
project.